From 5c47b7e22a46b343a879ded35b8035d7514bdeda Mon Sep 17 00:00:00 2001 From: Tayeb Mokni Date: Tue, 26 May 2026 23:49:37 +0200 Subject: [PATCH 1/3] feat(db): pgx-aware Prometheus collector with pool stats + query latency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #165. Adds packages/go/db/metrics.go: a prometheus.Collector implementation that pulls pgxpool.Stat() at scrape time and emits the gonext_db_pool_* gauges + cumulative counters defined in docs/10-observability.md §5.3. Histograms for query duration (gonext_db_query_duration_seconds) and transaction duration (gonext_db_tx_duration_seconds) are push-based, fed via ObserveQuery / ObserveTx so callers wire them at the data-access layer. Replication lag (gonext_db_replication_lag_seconds) is gated on a caller-supplied ReplicaProber; deployments without a replica leave it nil and the gauge is skipped. ReplicaProberFunc adapts a plain function for callers that want a closure over a replica pool. Wires the collector into apps/api/cmd/server/main.go alongside the existing metrics registry so /metrics exposes the new series without any operator action. Signed-off-by: Tayeb Mokni --- apps/api/cmd/server/main.go | 12 ++ packages/go/db/metrics.go | 376 +++++++++++++++++++++++++++++++++ packages/go/db/metrics_test.go | 173 +++++++++++++++ 3 files changed, 561 insertions(+) create mode 100644 packages/go/db/metrics.go create mode 100644 packages/go/db/metrics_test.go diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 55ee1b42..c58ebf10 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -268,6 +268,18 @@ func run(ctx context.Context) error { // middleware (issue #158) registers gonext_http_* against the same // registry so scrapers see them on the dedicated /metrics endpoint. metricsReg := gonextmetrics.NewRegistry() + + // pgx-aware Prometheus collector (#165). Surfaces pool stats + // (open/in-use/idle/wait), query/transaction duration histograms, + // and — when a replica prober is wired in a follow-up — replication + // lag. Registered against the same registry as the runtime + // collectors so the /metrics endpoint exposes gonext_db_* alongside + // go_* / process_*. The collector pulls pool stats at scrape time; + // there's no background goroutine to drain. + metricsReg.MustRegister(db.NewCollector(pool, db.CollectorOptions{ + DBLabel: "primary", + Logger: logger, + })) orch.MustRegister(logger, "metrics.flusher", noopCloser("metrics")) // Audit emitter. The PostgresStore writes to the audit_log table diff --git a/packages/go/db/metrics.go b/packages/go/db/metrics.go new file mode 100644 index 00000000..6a487b84 --- /dev/null +++ b/packages/go/db/metrics.go @@ -0,0 +1,376 @@ +// Package db's metrics.go implements a pgx-aware Prometheus collector +// that exposes pool statistics, named-query latency, and (when the +// replica handle is wired) replication lag. +// +// The collector lives in this package because pool.go is the only +// place in the codebase that owns *pgxpool.Pool — coupling the +// collector to a different package would push us toward exporting the +// pool handle or building yet another adapter. Per docs/10-observability.md +// §5.3 the metric names are part of the public contract; renaming any +// of them is a breaking change for dashboards and alert rules. +// +// Wiring: +// +// pool, err := db.New(ctx, cfg.Database, logger) +// ... +// collector := db.NewCollector(pool, db.CollectorOptions{ +// DBLabel: "primary", +// }) +// metricsReg.MustRegister(collector) +// +// The collector implements prometheus.Collector directly (rather than +// going through a CounterVec/GaugeVec) because pool stats are pull-based +// — pgxpool.Pool.Stat() is a snapshot, not a stream. Calling Stat() in +// Collect avoids the bookkeeping cost of a poll goroutine, and the +// scrape-time cost is one mutex-free read of the pgxpool counters. +// +// Histograms for query duration / transaction duration ARE registered +// up-front (they're write-once collectors fed by ObserveQuery / +// ObserveTx), so the same registry holds both the Collector and the +// histograms. +// +// Issue #165. +package db + +import ( + "context" + "log/slog" + "sync/atomic" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/prometheus/client_golang/prometheus" +) + +// Metric names. The gonext_db_ prefix matches docs/10-observability.md +// §5.3. Counter names end in _total per the Prometheus convention; +// gauges and histograms do not. +// +// These names are part of our public observability contract — they're +// referenced by dashboards, alert rules, and SLO docs. Renaming any of +// them is a breaking change. +const ( + metricQueryDuration = "gonext_db_query_duration_seconds" + metricTxDuration = "gonext_db_tx_duration_seconds" + metricPoolOpenConnections = "gonext_db_pool_open_connections" + metricPoolInUse = "gonext_db_pool_in_use" + metricPoolIdle = "gonext_db_pool_idle" + metricPoolMaxConns = "gonext_db_pool_max_conns" + metricPoolWaitSeconds = "gonext_db_pool_wait_seconds_total" + metricPoolWaitCount = "gonext_db_pool_wait_count_total" + metricPoolAcquireCount = "gonext_db_pool_acquire_total" + metricPoolNewConns = "gonext_db_pool_new_conns_total" + metricReplicationLag = "gonext_db_replication_lag_seconds" +) + +// dbLatencyBuckets duplicates packages/go/metrics.DBLatencyBuckets to +// avoid an import cycle (packages/go/metrics depends on packages/go/db +// for the Registry seed path? no — but keeping this local removes any +// future risk of one). The values are identical and the docs in +// metrics/buckets.go remain the source of truth for the rationale. +var dbLatencyBuckets = []float64{ + 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, +} + +// ReplicaProber is the contract for replication-lag probing. The +// CollectorOptions takes one; production wiring against a streaming +// replica passes an implementation that runs +// `SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))` +// against the replica's pool. Deployments without a replica leave the +// field nil and the gauge is skipped from the scrape. +// +// We isolate this behind an interface because: +// +// - Not every deployment has a replica handle (single-DB +// installs). +// - Even when one exists, the lag query is dialect-specific +// (Aurora exposes it differently than vanilla Postgres, and managed +// services expose a `aws_rds.repl_lag` or similar). Keeping the +// query out of this file lets each deployment plumb the +// appropriate one without touching the collector. +// +// LagSeconds returns the most recent measured lag (seconds behind +// primary). Returning math.NaN suppresses the sample, matching the +// Prometheus convention that NaN is "no value". +type ReplicaProber interface { + LagSeconds(ctx context.Context) (float64, error) +} + +// CollectorOptions configures NewCollector. All fields are optional; +// zero values produce a collector wired for a single-replica deployment +// with the primary labeled "primary". +type CollectorOptions struct { + // DBLabel is the value of the `db` label on every emitted series. + // Defaults to "primary". Set to "primary" / "replica" / etc. when + // multiple pools are registered in the same binary. + DBLabel string + + // Replica, when non-nil, is consulted on every scrape to emit + // gonext_db_replication_lag_seconds. The value's `replica` label + // is taken from ReplicaLabel. nil disables the gauge entirely. + Replica ReplicaProber + + // ReplicaLabel is the value of the `replica` label on the + // replication-lag gauge. Defaults to "default" when Replica is set + // and the label is empty. + ReplicaLabel string + + // Logger receives warnings when a replica lag probe fails. nil + // suppresses logging; production wiring always passes the binary + // logger so transient lag-probe failures show up in the structured + // log stream. + Logger *slog.Logger + + // ProbeTimeout bounds the per-scrape replica probe. Defaults to + // 1 second — a slow lag probe must not slow the /metrics scrape + // past the Prometheus default scrape_timeout (10s) by enough to + // matter, and a 1s budget is plenty for a healthy LAN replica. + ProbeTimeout time.Duration +} + +const defaultProbeTimeout = 1 * time.Second + +// Collector implements prometheus.Collector for a single *pgxpool.Pool. +// It exposes both pull-based gauges (pool stats sampled at Collect +// time) and push-based histograms (query / transaction duration, fed +// by ObserveQuery / ObserveTx). +// +// Safe for concurrent use; pgxpool.Pool.Stat() is itself goroutine-safe. +type Collector struct { + pool *pgxpool.Pool + opts CollectorOptions + + // Pull-based descriptors. Built once in NewCollector so Describe + // and Collect emit the same Desc instances (a Prometheus contract: + // every Collect must emit metrics whose Desc was returned by + // Describe; mismatches log a warning and drop the metric). + openConns *prometheus.Desc + inUse *prometheus.Desc + idle *prometheus.Desc + maxConns *prometheus.Desc + waitSeconds *prometheus.Desc + waitCount *prometheus.Desc + acquireCount *prometheus.Desc + newConns *prometheus.Desc + replLag *prometheus.Desc + + // Push-based histograms. Fed by ObserveQuery / ObserveTx. + // Registered alongside the Collector via the same MustRegister + // call so callers don't need a two-step wiring. We hold the + // HistogramVec rather than the bare Desc because callers reach in + // through ObserveQuery to record samples. + queryHist *prometheus.HistogramVec + txHist *prometheus.HistogramVec + + // replProbeFailures counts probe errors; surfaced through the + // logger but kept internally as an atomic for quiet bursts (we + // don't want a flapping replica to spam the log on every scrape). + replProbeFailures atomic.Int64 +} + +// NewCollector builds a Collector against pool. Callers register the +// returned value with their Prometheus registry: +// +// collector := db.NewCollector(pool, db.CollectorOptions{ +// DBLabel: "primary", +// }) +// metricsReg.Prometheus().MustRegister(collector) +// +// pool MUST be non-nil — there's no graceful degradation path for a +// collector without a pool to introspect, and an early panic at +// startup is preferable to a silent always-zero gauge in production. +func NewCollector(pool *pgxpool.Pool, opts CollectorOptions) *Collector { + if pool == nil { + panic("db.NewCollector: pool is required") + } + if opts.DBLabel == "" { + opts.DBLabel = "primary" + } + if opts.Replica != nil && opts.ReplicaLabel == "" { + opts.ReplicaLabel = "default" + } + if opts.ProbeTimeout <= 0 { + opts.ProbeTimeout = defaultProbeTimeout + } + + labels := []string{"db"} + c := &Collector{ + pool: pool, + opts: opts, + openConns: prometheus.NewDesc( + metricPoolOpenConnections, + "Current number of open connections in the pool (acquired + idle).", + labels, nil, + ), + inUse: prometheus.NewDesc( + metricPoolInUse, + "Number of connections currently checked out by callers.", + labels, nil, + ), + idle: prometheus.NewDesc( + metricPoolIdle, + "Number of idle connections sitting in the pool.", + labels, nil, + ), + maxConns: prometheus.NewDesc( + metricPoolMaxConns, + "Maximum number of connections the pool may open.", + labels, nil, + ), + waitSeconds: prometheus.NewDesc( + metricPoolWaitSeconds, + "Cumulative wall-clock time spent waiting for a connection from the pool.", + labels, nil, + ), + waitCount: prometheus.NewDesc( + metricPoolWaitCount, + "Cumulative count of acquisitions that had to wait because the pool was exhausted.", + labels, nil, + ), + acquireCount: prometheus.NewDesc( + metricPoolAcquireCount, + "Cumulative count of successful pool acquisitions.", + labels, nil, + ), + newConns: prometheus.NewDesc( + metricPoolNewConns, + "Cumulative count of new physical connections the pool has opened.", + labels, nil, + ), + replLag: prometheus.NewDesc( + metricReplicationLag, + "Seconds the configured replica is behind the primary (NaN when probe fails).", + []string{"replica"}, nil, + ), + queryHist: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: metricQueryDuration, + Help: "Per-named-query latency in seconds.", + Buckets: dbLatencyBuckets, + }, []string{"query_name", "op"}), + txHist: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: metricTxDuration, + Help: "Per-named-transaction duration in seconds.", + Buckets: dbLatencyBuckets, + }, []string{"tx_name"}), + } + return c +} + +// Describe implements prometheus.Collector. Emits every Desc the +// collector might ever produce. +func (c *Collector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.openConns + ch <- c.inUse + ch <- c.idle + ch <- c.maxConns + ch <- c.waitSeconds + ch <- c.waitCount + ch <- c.acquireCount + ch <- c.newConns + if c.opts.Replica != nil { + ch <- c.replLag + } + c.queryHist.Describe(ch) + c.txHist.Describe(ch) +} + +// Collect implements prometheus.Collector. Reads pgxpool.Stat() once +// per scrape and emits every pool-stats gauge plus the replication-lag +// gauge (when a replica prober is configured). +// +// pgxpool.Stat() is a snapshot — the values are read from internal +// atomics in pgxpool, so the call is cheap and lock-free relative to +// the connection-acquisition fast path. +func (c *Collector) Collect(ch chan<- prometheus.Metric) { + stat := c.pool.Stat() + db := c.opts.DBLabel + + // AcquiredConns + IdleConns equals TotalConns; we emit each so + // dashboards can break the pool down without computing the sum + // from a recording rule. + ch <- prometheus.MustNewConstMetric(c.openConns, prometheus.GaugeValue, float64(stat.TotalConns()), db) + ch <- prometheus.MustNewConstMetric(c.inUse, prometheus.GaugeValue, float64(stat.AcquiredConns()), db) + ch <- prometheus.MustNewConstMetric(c.idle, prometheus.GaugeValue, float64(stat.IdleConns()), db) + ch <- prometheus.MustNewConstMetric(c.maxConns, prometheus.GaugeValue, float64(stat.MaxConns()), db) + ch <- prometheus.MustNewConstMetric(c.waitSeconds, prometheus.CounterValue, stat.AcquireDuration().Seconds(), db) + ch <- prometheus.MustNewConstMetric(c.waitCount, prometheus.CounterValue, float64(stat.EmptyAcquireCount()), db) + ch <- prometheus.MustNewConstMetric(c.acquireCount, prometheus.CounterValue, float64(stat.AcquireCount()), db) + ch <- prometheus.MustNewConstMetric(c.newConns, prometheus.CounterValue, float64(stat.NewConnsCount()), db) + + if c.opts.Replica != nil { + ctx, cancel := context.WithTimeout(context.Background(), c.opts.ProbeTimeout) + defer cancel() + lag, err := c.opts.Replica.LagSeconds(ctx) + if err != nil { + // Increment internal failure counter; warn rate-limited + // via slog (handler can apply its own throttling). + n := c.replProbeFailures.Add(1) + if c.opts.Logger != nil { + c.opts.Logger.Warn("db: replica lag probe failed", + slog.String("replica", c.opts.ReplicaLabel), + slog.Int64("consecutive_failures", n), + slog.String("err", err.Error()), + ) + } + // Skip the sample on error — Prometheus treats absence as + // staleness, which is the right semantic here. + } else { + c.replProbeFailures.Store(0) + ch <- prometheus.MustNewConstMetric(c.replLag, prometheus.GaugeValue, lag, c.opts.ReplicaLabel) + } + } + + c.queryHist.Collect(ch) + c.txHist.Collect(ch) +} + +// ObserveQuery records the elapsed duration of a named query against +// the histogram. queryName is the code-defined identifier (e.g. +// "posts.list", "user.lookup_by_email") — NOT the SQL text. op is one +// of "select" / "insert" / "update" / "delete" / "exec" / "tx". +// +// Callers wire this around their pgx Query / Exec / QueryRow calls: +// +// start := time.Now() +// rows, err := pool.Query(ctx, sql) +// collector.ObserveQuery("posts.list", "select", time.Since(start)) +// +// The (queryName, op) cardinality is deliberately bounded — operators +// who add a new named query also add a new series. Unbounded labels +// (raw SQL, parameter values) would blow the cardinality budget and +// must NEVER be passed here. +func (c *Collector) ObserveQuery(queryName, op string, dur time.Duration) { + if c == nil { + return + } + c.queryHist.WithLabelValues(queryName, op).Observe(dur.Seconds()) +} + +// ObserveTx records the elapsed duration of a named transaction. The +// txName is the code-defined identifier; the same cardinality +// constraints as ObserveQuery apply. +func (c *Collector) ObserveTx(txName string, dur time.Duration) { + if c == nil { + return + } + c.txHist.WithLabelValues(txName).Observe(dur.Seconds()) +} + +// ReplicaProberFunc adapts a plain function to the ReplicaProber +// interface, for callers that don't want to spin up a dedicated type. +// Production wiring typically uses this with a closure over a separate +// replica *pgxpool.Pool: +// +// prober := db.ReplicaProberFunc(func(ctx context.Context) (float64, error) { +// var secs float64 +// err := replicaPool.QueryRow(ctx, +// "SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))", +// ).Scan(&secs) +// return secs, err +// }) +type ReplicaProberFunc func(ctx context.Context) (float64, error) + +// LagSeconds implements ReplicaProber. +func (f ReplicaProberFunc) LagSeconds(ctx context.Context) (float64, error) { + return f(ctx) +} diff --git a/packages/go/db/metrics_test.go b/packages/go/db/metrics_test.go new file mode 100644 index 00000000..5716f09d --- /dev/null +++ b/packages/go/db/metrics_test.go @@ -0,0 +1,173 @@ +package db + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Singleton-Solution/GoNext/packages/go/config" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +// TestNewCollector_PanicsOnNilPool verifies the documented precondition. +// A nil pool is a wiring bug; an early panic at startup is preferable +// to a silent always-zero gauge in production. +func TestNewCollector_PanicsOnNilPool(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic on nil pool") + } + msg, ok := r.(string) + if !ok { + t.Fatalf("expected string panic, got %T", r) + } + if !strings.Contains(msg, "pool is required") { + t.Fatalf("unexpected panic message: %s", msg) + } + }() + _ = NewCollector(nil, CollectorOptions{}) +} + +// TestNewCollector_AppliesDefaults checks that the zero CollectorOptions +// gets sensible labels and timeout. +func TestNewCollector_AppliesDefaults(t *testing.T) { + dsn := dsnFromEnv(t) + pool, err := New(context.Background(), config.DatabaseConfig{ + URL: dsn, + MaxOpenConns: 2, + }, quietLogger()) + if err != nil { + t.Fatalf("db.New: %v", err) + } + defer pool.Close() + + c := NewCollector(pool, CollectorOptions{}) + if c.opts.DBLabel != "primary" { + t.Errorf("DBLabel default: got %q want %q", c.opts.DBLabel, "primary") + } + if c.opts.ProbeTimeout != defaultProbeTimeout { + t.Errorf("ProbeTimeout default: got %v want %v", c.opts.ProbeTimeout, defaultProbeTimeout) + } +} + +// TestCollector_Collect_EmitsPoolStats verifies that a Collect cycle +// against a live pool produces non-empty samples for every pool gauge. +// Counter values are checked for monotonicity (newConns >= 0). +func TestCollector_Collect_EmitsPoolStats(t *testing.T) { + dsn := dsnFromEnv(t) + pool, err := New(context.Background(), config.DatabaseConfig{ + URL: dsn, + MaxOpenConns: 4, + }, quietLogger()) + if err != nil { + t.Fatalf("db.New: %v", err) + } + defer pool.Close() + + reg := prometheus.NewRegistry() + c := NewCollector(pool, CollectorOptions{DBLabel: "primary"}) + reg.MustRegister(c) + + // Sanity: scrape and confirm at least one sample comes through for + // each pool gauge. We use testutil.CollectAndCount which exercises + // the full Describe→Collect contract. + for _, name := range []string{ + metricPoolOpenConnections, + metricPoolInUse, + metricPoolIdle, + metricPoolMaxConns, + metricPoolWaitSeconds, + metricPoolWaitCount, + metricPoolAcquireCount, + metricPoolNewConns, + } { + count := testutil.CollectAndCount(c, name) + if count == 0 { + t.Errorf("no samples emitted for %s", name) + } + } +} + +// TestCollector_ObserveQuery_RecordsHistogram verifies the push-side of +// the collector: observations land in the histogram and are emitted on +// scrape. +func TestCollector_ObserveQuery_RecordsHistogram(t *testing.T) { + // Histogram observations don't need a live pool — but NewCollector + // requires non-nil pool. Use a no-DB shortcut: a fake handle that + // only needs to be non-nil to pass the precondition. We don't call + // Collect (which would try to call pool.Stat()) in this test. + c := &Collector{ + queryHist: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: metricQueryDuration, + Help: "test", + Buckets: dbLatencyBuckets, + }, []string{"query_name", "op"}), + txHist: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: metricTxDuration, + Help: "test", + Buckets: dbLatencyBuckets, + }, []string{"tx_name"}), + } + + c.ObserveQuery("posts.list", "select", 12*time.Millisecond) + c.ObserveQuery("posts.list", "select", 30*time.Millisecond) + c.ObserveTx("write_post", 80*time.Millisecond) + + // CollectAndCount exercises the full Describe→Collect contract and + // returns the number of series; a histogram emits one series + // (_count, _sum, _bucket together) per label-value combination. + if got := testutil.CollectAndCount(c.queryHist, metricQueryDuration); got != 1 { + t.Errorf("query histogram series count: got %d want 1", got) + } + if got := testutil.CollectAndCount(c.txHist, metricTxDuration); got != 1 { + t.Errorf("tx histogram series count: got %d want 1", got) + } +} + +// TestCollector_ObserveQuery_NilReceiverIsSafe documents the +// nil-tolerant contract — callers that wire the collector +// conditionally (e.g. metrics disabled in tests) can still call +// ObserveQuery without crashing. +func TestCollector_ObserveQuery_NilReceiverIsSafe(t *testing.T) { + var c *Collector + c.ObserveQuery("x", "select", time.Millisecond) + c.ObserveTx("x", time.Millisecond) +} + +// TestReplicaProberFunc_Adapter verifies the function adapter +// forwards calls. +func TestReplicaProberFunc_Adapter(t *testing.T) { + var captured context.Context + prober := ReplicaProberFunc(func(ctx context.Context) (float64, error) { + captured = ctx + return 1.5, nil + }) + ctx := context.Background() + v, err := prober.LagSeconds(ctx) + if err != nil { + t.Fatalf("LagSeconds: %v", err) + } + if v != 1.5 { + t.Errorf("value: got %v want 1.5", v) + } + if captured != ctx { + t.Error("ctx not forwarded") + } +} + +// TestReplicaProberFunc_PropagatesError ensures errors flow through the +// adapter unchanged. +func TestReplicaProberFunc_PropagatesError(t *testing.T) { + sentinel := errors.New("replica probe failed") + prober := ReplicaProberFunc(func(_ context.Context) (float64, error) { + return 0, sentinel + }) + _, err := prober.LagSeconds(context.Background()) + if !errors.Is(err, sentinel) { + t.Errorf("err: got %v want %v", err, sentinel) + } +} From 8513a8ec6428d4369600b2c5b9b6cd2f537035f9 Mon Sep 17 00:00:00 2001 From: Tayeb Mokni Date: Tue, 26 May 2026 23:52:22 +0200 Subject: [PATCH 2/3] feat(jobs/asynq): Inspector-driven queue depth, lag, retries, DLQ metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #172. The existing metrics.go covered processed/failed/inflight/unknown, which are sourced from handler-side middleware and only reflect the in-process worker. The cluster-wide queue state — pending tasks, processing lag, retry pool, archived (dead-letter) tasks, paused-ness — lives in Redis and is accessed through Asynq's Inspector API. Adds InspectorCollector: a prometheus.Collector that calls Inspector.GetQueueInfo for each configured queue on every scrape and emits gonext_jobs_queue_depth, gonext_jobs_queue_active, gonext_jobs_queue_lag_seconds, gonext_jobs_retries, gonext_jobs_dlq_size, and gonext_jobs_queue_paused. Failures are logged + counted on gonext_jobs_inspector_failures_total so operators can alert on a flapping Redis connection. Defines QueueInspector as the minimal interface the collector needs, so tests use a fakeInspector without standing up Redis. *asynq.Inspector satisfies the interface directly. Wires the collector into apps/worker/cmd/worker/main.go alongside the existing metrics registry, with the inspector handle registered with the shutdown orchestrator so it drains cleanly on SIGTERM. Signed-off-by: Tayeb Mokni --- apps/worker/cmd/worker/main.go | 29 +++ packages/go/jobs/asynq/metrics.go | 235 +++++++++++++++++++++++++ packages/go/jobs/asynq/metrics_test.go | 214 ++++++++++++++++++++++ 3 files changed, 478 insertions(+) create mode 100644 packages/go/jobs/asynq/metrics_test.go diff --git a/apps/worker/cmd/worker/main.go b/apps/worker/cmd/worker/main.go index 635a6cde..4c30321b 100644 --- a/apps/worker/cmd/worker/main.go +++ b/apps/worker/cmd/worker/main.go @@ -118,6 +118,35 @@ func run(ctx context.Context) error { return fmt.Errorf("jobs/asynq: %w", err) } + // Inspector-driven queue metrics (#172). Asynq's Inspector talks + // to Redis and exposes the cluster-wide queue state — pending, + // active, retry, archived, latency. Sampling once per /metrics + // scrape keeps the wiring stateless; no background goroutine, no + // shared mutable state, no shutdown ordering surprises beyond + // closing the Inspector itself. + // + // The inspector owns its own Redis connection pool (separate from + // the asynq.Server's), so we register it with the orchestrator + // after the server's queue.consumer registration — LIFO drain + // closes the inspector before the consumer, ensuring no in-flight + // inspector call sees a half-closed Redis client. + inspector := asynq.NewInspector(redisOpt) + mreg.MustRegister(jobsasynq.NewInspectorCollector(inspector, jobsasynq.InspectorCollectorOptions{ + Queues: []string{ + jobsasynq.QueueCritical, + jobsasynq.QueueWebhook, + jobsasynq.QueueEmail, + jobsasynq.QueueMedia, + jobsasynq.QueueMigration, + jobsasynq.QueuePlugin, + jobsasynq.QueueDefault, + }, + Logger: logger, + })) + orch.MustRegister(logger, "asynq.inspector", func(_ context.Context) error { + return inspector.Close() + }) + // Heavy-media tasks. Registered in stub mode for the boot-time // skeleton — the package consults the PATH and the wired storage // handles to decide between the real handler and the stub. diff --git a/packages/go/jobs/asynq/metrics.go b/packages/go/jobs/asynq/metrics.go index 85d02760..3b2956ab 100644 --- a/packages/go/jobs/asynq/metrics.go +++ b/packages/go/jobs/asynq/metrics.go @@ -1,6 +1,12 @@ package asynq import ( + "context" + "log/slog" + "sync/atomic" + "time" + + "github.com/hibiken/asynq" "github.com/prometheus/client_golang/prometheus" ) @@ -16,6 +22,18 @@ const ( metricFailed = "gonext_jobs_failed_total" metricInflight = "gonext_jobs_inflight" metricUnknown = "gonext_jobs_unknown_total" + + // Inspector-driven series (issue #172). These are sampled from + // Asynq's Inspector API rather than from the in-process handler + // middleware, so they reflect cluster-wide state (the queue lives + // in Redis and is shared across every worker replica). + metricQueueDepth = "gonext_jobs_queue_depth" + metricQueueActive = "gonext_jobs_queue_active" + metricQueueLagSecs = "gonext_jobs_queue_lag_seconds" + metricRetries = "gonext_jobs_retries" + metricDLQSize = "gonext_jobs_dlq_size" + metricQueuePaused = "gonext_jobs_queue_paused" + metricInspectorFail = "gonext_jobs_inspector_failures_total" ) // metrics bundles the four Prometheus collectors emitted by the chassis. @@ -111,3 +129,220 @@ func (m *metrics) observeUnknown(queue string) { } m.unknown.WithLabelValues(queue).Inc() } + +// QueueInspector is the subset of *asynq.Inspector that the +// InspectorCollector needs. Defined as an interface so tests can +// supply a fake without standing up Redis. Production wiring passes +// *asynq.Inspector directly — its method signatures satisfy the +// interface. +// +// The two methods cover the full state set: +// +// - GetQueueInfo returns Pending / Active / Retry / Archived / +// Latency / Paused for one queue. We sample this for each +// configured queue on every scrape. +// +// - Queues returns the full set of queue names known to the cluster. +// We use this to default the Collector's queue list when the +// caller doesn't pass one — it's the right default for an +// operator who just wants "everything". +type QueueInspector interface { + GetQueueInfo(queue string) (*asynq.QueueInfo, error) + Queues() ([]string, error) + Close() error +} + +// InspectorCollectorOptions configures NewInspectorCollector. All +// fields are optional; the zero value samples every queue Asynq knows +// about and emits one warning per inspector failure. +type InspectorCollectorOptions struct { + // Queues, when non-empty, restricts sampling to the named queues. + // Empty means "ask Inspector.Queues() once per Collect and sample + // all of them" — appropriate for a single-tenant deployment where + // every queue should be on the dashboard. Production wiring with + // known queue names should pass them here explicitly so a typo'd + // queue name in a publisher doesn't silently inflate the + // cardinality. + Queues []string + + // Logger receives Warn lines on Inspector failures. nil suppresses + // logging; production wiring always passes the binary logger. + Logger *slog.Logger + + // ProbeTimeout bounds the per-scrape Inspector calls. Defaults to + // 2 seconds — Asynq's Inspector talks to Redis, and a stalled + // Redis must not block the /metrics scrape past Prometheus's + // default scrape_timeout. Two seconds gives plenty of headroom on + // a healthy LAN while still surfacing problems quickly. + ProbeTimeout time.Duration +} + +const defaultInspectorProbeTimeout = 2 * time.Second + +// InspectorCollector implements prometheus.Collector by sampling +// Asynq's Inspector API on every scrape. Exposes queue depth (Pending), +// active count, processing lag (Latency of the oldest pending task), +// retries gauge, and dead-letter (Archived) size — completing the +// observability story that the handler-side metrics start. +// +// One Collector per worker process suffices; the Inspector reads from +// Redis, which is shared across replicas, so each replica's scrape +// gets the same cluster-wide view. We don't deduplicate across +// replicas — Prometheus will scrape each replica's /metrics and the +// recording rules sum/avg as needed. +// +// Safe for concurrent use; the underlying Inspector is goroutine-safe. +type InspectorCollector struct { + inspector QueueInspector + opts InspectorCollectorOptions + + depth *prometheus.Desc + active *prometheus.Desc + lagSecs *prometheus.Desc + retries *prometheus.Desc + dlqSize *prometheus.Desc + paused *prometheus.Desc + probeFails *prometheus.Desc + failureCount atomic.Int64 +} + +// NewInspectorCollector builds an InspectorCollector against +// inspector. The inspector must be non-nil — a nil inspector is a +// wiring bug and an early panic is preferable to a silent always-zero +// dashboard in production. +// +// Wiring (worker main.go): +// +// inspector := asynq.NewInspector(redisOpt) +// collector := jobsasynq.NewInspectorCollector(inspector, jobsasynq.InspectorCollectorOptions{ +// Queues: []string{ +// jobsasynq.QueueCritical, jobsasynq.QueueWebhook, ... +// }, +// Logger: logger, +// }) +// metricsReg.MustRegister(collector) +// orch.MustRegister(logger, "asynq.inspector", shutdown.CloserFromIO(inspector)) +func NewInspectorCollector(inspector QueueInspector, opts InspectorCollectorOptions) *InspectorCollector { + if inspector == nil { + panic("jobs/asynq.NewInspectorCollector: inspector is required") + } + if opts.ProbeTimeout <= 0 { + opts.ProbeTimeout = defaultInspectorProbeTimeout + } + + labels := []string{"queue"} + return &InspectorCollector{ + inspector: inspector, + opts: opts, + depth: prometheus.NewDesc( + metricQueueDepth, + "Number of pending tasks in the queue (cluster-wide).", + labels, nil, + ), + active: prometheus.NewDesc( + metricQueueActive, + "Number of tasks currently being processed across all workers, by queue.", + labels, nil, + ), + lagSecs: prometheus.NewDesc( + metricQueueLagSecs, + "Age in seconds of the oldest pending task in the queue (processing lag).", + labels, nil, + ), + retries: prometheus.NewDesc( + metricRetries, + "Number of tasks scheduled for retry after a handler failure, by queue.", + labels, nil, + ), + dlqSize: prometheus.NewDesc( + metricDLQSize, + "Number of tasks in the dead-letter queue (archived after exhausting retries), by queue.", + labels, nil, + ), + paused: prometheus.NewDesc( + metricQueuePaused, + "1 when the queue is paused (operators stopped dispatch); 0 otherwise.", + labels, nil, + ), + probeFails: prometheus.NewDesc( + metricInspectorFail, + "Cumulative count of Inspector probe failures during /metrics scrapes.", + nil, nil, + ), + } +} + +// Describe implements prometheus.Collector. +func (c *InspectorCollector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.depth + ch <- c.active + ch <- c.lagSecs + ch <- c.retries + ch <- c.dlqSize + ch <- c.paused + ch <- c.probeFails +} + +// Collect implements prometheus.Collector. For each configured queue +// (or, if none configured, the full set returned by Inspector.Queues), +// issues one GetQueueInfo call and emits all six per-queue gauges. +// +// Per-queue probe failures are logged and counted, but never propagate +// — a transient Redis blip should not cause Prometheus to lose the +// rest of the scrape. The probe-failure counter is itself a Prometheus +// gauge so operators can alert on it. +func (c *InspectorCollector) Collect(ch chan<- prometheus.Metric) { + ctx, cancel := context.WithTimeout(context.Background(), c.opts.ProbeTimeout) + defer cancel() + + queues := c.opts.Queues + if len(queues) == 0 { + discovered, err := c.inspector.Queues() + if err != nil { + c.recordProbeFailure("queues_discovery", err) + } else { + queues = discovered + } + } + + for _, q := range queues { + // Respect the scrape-level timeout: if ctx is done, stop + // emitting rather than blocking on more Redis round-trips. + if err := ctx.Err(); err != nil { + c.recordProbeFailure("context_deadline_exceeded", err) + break + } + info, err := c.inspector.GetQueueInfo(q) + if err != nil { + c.recordProbeFailure(q, err) + continue + } + ch <- prometheus.MustNewConstMetric(c.depth, prometheus.GaugeValue, float64(info.Pending), q) + ch <- prometheus.MustNewConstMetric(c.active, prometheus.GaugeValue, float64(info.Active), q) + ch <- prometheus.MustNewConstMetric(c.lagSecs, prometheus.GaugeValue, info.Latency.Seconds(), q) + ch <- prometheus.MustNewConstMetric(c.retries, prometheus.GaugeValue, float64(info.Retry), q) + ch <- prometheus.MustNewConstMetric(c.dlqSize, prometheus.GaugeValue, float64(info.Archived), q) + var paused float64 + if info.Paused { + paused = 1 + } + ch <- prometheus.MustNewConstMetric(c.paused, prometheus.GaugeValue, paused, q) + } + + ch <- prometheus.MustNewConstMetric(c.probeFails, prometheus.CounterValue, float64(c.failureCount.Load())) +} + +// recordProbeFailure logs and counts an Inspector failure. We +// emit a single Warn line per failure — operators tune Prometheus's +// scrape interval and the Inspector probe runs once per scrape, so the +// log rate is bounded by the scrape cadence rather than by the in-flight +// task volume. +func (c *InspectorCollector) recordProbeFailure(queue string, err error) { + c.failureCount.Add(1) + if c.opts.Logger != nil { + c.opts.Logger.Warn("jobs/asynq: inspector probe failed; skipping queue sample", + slog.String("queue", queue), + slog.String("err", err.Error()), + ) + } +} diff --git a/packages/go/jobs/asynq/metrics_test.go b/packages/go/jobs/asynq/metrics_test.go new file mode 100644 index 00000000..85d5d5eb --- /dev/null +++ b/packages/go/jobs/asynq/metrics_test.go @@ -0,0 +1,214 @@ +package asynq + +import ( + "errors" + "io" + "log/slog" + "strings" + "sync" + "testing" + "time" + + "github.com/hibiken/asynq" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +// fakeInspector is a goroutine-safe stand-in for *asynq.Inspector. The +// per-queue info map and the Queues slice are seeded by the test; +// GetQueueInfo errors for the listed names so we can exercise the +// failure path without standing up Redis. +type fakeInspector struct { + mu sync.Mutex + queues []string + info map[string]*asynq.QueueInfo + errs map[string]error + queuesErr error +} + +func (f *fakeInspector) Queues() ([]string, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.queuesErr != nil { + return nil, f.queuesErr + } + out := make([]string, len(f.queues)) + copy(out, f.queues) + return out, nil +} + +func (f *fakeInspector) GetQueueInfo(name string) (*asynq.QueueInfo, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err, ok := f.errs[name]; ok { + return nil, err + } + if i, ok := f.info[name]; ok { + return i, nil + } + return nil, errors.New("unknown queue") +} + +func (f *fakeInspector) Close() error { return nil } + +func discardLogger() *slog.Logger { + return slog.New(slog.NewJSONHandler(io.Discard, nil)) +} + +// TestNewInspectorCollector_PanicsOnNilInspector verifies the +// documented precondition. +func TestNewInspectorCollector_PanicsOnNilInspector(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic on nil inspector") + } + msg, ok := r.(string) + if !ok || !strings.Contains(msg, "inspector is required") { + t.Fatalf("unexpected panic: %v", r) + } + }() + _ = NewInspectorCollector(nil, InspectorCollectorOptions{}) +} + +// TestInspectorCollector_Collect_EmitsPerQueueGauges verifies the +// gauges are emitted with the right values for every configured queue. +func TestInspectorCollector_Collect_EmitsPerQueueGauges(t *testing.T) { + insp := &fakeInspector{ + info: map[string]*asynq.QueueInfo{ + QueueCritical: { + Queue: QueueCritical, + Pending: 12, + Active: 3, + Retry: 1, + Archived: 2, + Latency: 15 * time.Second, + Paused: false, + }, + QueueDefault: { + Queue: QueueDefault, + Pending: 0, + Active: 0, + Retry: 0, + Archived: 0, + Paused: true, + }, + }, + } + c := NewInspectorCollector(insp, InspectorCollectorOptions{ + Queues: []string{QueueCritical, QueueDefault}, + Logger: discardLogger(), + }) + + expected := strings.NewReader(` +# HELP gonext_jobs_dlq_size Number of tasks in the dead-letter queue (archived after exhausting retries), by queue. +# TYPE gonext_jobs_dlq_size gauge +gonext_jobs_dlq_size{queue="critical"} 2 +gonext_jobs_dlq_size{queue="default"} 0 +# HELP gonext_jobs_queue_active Number of tasks currently being processed across all workers, by queue. +# TYPE gonext_jobs_queue_active gauge +gonext_jobs_queue_active{queue="critical"} 3 +gonext_jobs_queue_active{queue="default"} 0 +# HELP gonext_jobs_queue_depth Number of pending tasks in the queue (cluster-wide). +# TYPE gonext_jobs_queue_depth gauge +gonext_jobs_queue_depth{queue="critical"} 12 +gonext_jobs_queue_depth{queue="default"} 0 +# HELP gonext_jobs_queue_lag_seconds Age in seconds of the oldest pending task in the queue (processing lag). +# TYPE gonext_jobs_queue_lag_seconds gauge +gonext_jobs_queue_lag_seconds{queue="critical"} 15 +gonext_jobs_queue_lag_seconds{queue="default"} 0 +# HELP gonext_jobs_queue_paused 1 when the queue is paused (operators stopped dispatch); 0 otherwise. +# TYPE gonext_jobs_queue_paused gauge +gonext_jobs_queue_paused{queue="critical"} 0 +gonext_jobs_queue_paused{queue="default"} 1 +# HELP gonext_jobs_retries Number of tasks scheduled for retry after a handler failure, by queue. +# TYPE gonext_jobs_retries gauge +gonext_jobs_retries{queue="critical"} 1 +gonext_jobs_retries{queue="default"} 0 +`) + if err := testutil.CollectAndCompare(c, expected, + metricQueueDepth, + metricQueueActive, + metricQueueLagSecs, + metricRetries, + metricDLQSize, + metricQueuePaused, + ); err != nil { + t.Fatalf("CollectAndCompare: %v", err) + } +} + +// TestInspectorCollector_Collect_RecordsProbeFailureOnGetQueueInfoError +// exercises the per-queue failure path: a probe error is logged + the +// failure counter increments + the queue is skipped (rather than crashing). +func TestInspectorCollector_Collect_RecordsProbeFailureOnGetQueueInfoError(t *testing.T) { + insp := &fakeInspector{ + info: map[string]*asynq.QueueInfo{ + QueueEmail: {Queue: QueueEmail, Pending: 5}, + }, + errs: map[string]error{ + QueueWebhook: errors.New("redis down"), + }, + } + c := NewInspectorCollector(insp, InspectorCollectorOptions{ + Queues: []string{QueueWebhook, QueueEmail}, + Logger: discardLogger(), + }) + + // QueueEmail is healthy; QueueWebhook errors. CollectAndCount calls + // Collect once and tells us how many series came through for the + // metric name we ask about. After one scrape we should see exactly + // one series (Email) and the failure counter should sit at 1. + if got := testutil.CollectAndCount(c, metricQueueDepth); got != 1 { + t.Errorf("queue depth series: got %d want 1 (only email survived)", got) + } + if got := c.failureCount.Load(); got != 1 { + t.Errorf("failure count: got %d want 1", got) + } +} + +// TestInspectorCollector_Collect_DiscoversQueuesWhenUnset exercises the +// default-queues path: when opts.Queues is nil, Inspector.Queues() is +// consulted on every scrape. +func TestInspectorCollector_Collect_DiscoversQueuesWhenUnset(t *testing.T) { + insp := &fakeInspector{ + queues: []string{QueueMedia}, + info: map[string]*asynq.QueueInfo{ + QueueMedia: {Queue: QueueMedia, Pending: 9, Active: 1}, + }, + } + c := NewInspectorCollector(insp, InspectorCollectorOptions{ + Logger: discardLogger(), + }) + + if got := testutil.CollectAndCount(c, metricQueueDepth); got != 1 { + t.Errorf("queue depth series: got %d want 1", got) + } +} + +// TestInspectorCollector_Collect_QueuesDiscoveryFailureLogged covers +// the "Inspector.Queues() failed at scrape time" branch. +func TestInspectorCollector_Collect_QueuesDiscoveryFailureLogged(t *testing.T) { + insp := &fakeInspector{queuesErr: errors.New("queues down")} + c := NewInspectorCollector(insp, InspectorCollectorOptions{ + Logger: discardLogger(), + }) + + reg := prometheus.NewRegistry() + reg.MustRegister(c) + if _, err := reg.Gather(); err != nil { + t.Fatalf("Gather: %v", err) + } + if got := c.failureCount.Load(); got != 1 { + t.Errorf("failure count: got %d want 1", got) + } +} + +// TestInspectorCollector_ProbeTimeout_Default ensures a zero-valued +// option still produces a sensible probe budget. +func TestInspectorCollector_ProbeTimeout_Default(t *testing.T) { + c := NewInspectorCollector(&fakeInspector{}, InspectorCollectorOptions{}) + if c.opts.ProbeTimeout != defaultInspectorProbeTimeout { + t.Errorf("default probe timeout: got %v want %v", c.opts.ProbeTimeout, defaultInspectorProbeTimeout) + } +} From 3eb9702a54f24aec91772a503a89129432246502 Mon Sep 17 00:00:00 2001 From: Tayeb Mokni Date: Tue, 26 May 2026 23:56:47 +0200 Subject: [PATCH 3/3] feat(observability): Sentry/GlitchTip error tracking with plugin-aware grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #202. Adds packages/go/observability/errortracker: a thin wrapper around getsentry/sentry-go that exposes Init / Capture / CaptureMessage / Recover / WithPluginSlug and isolates the rest of the codebase from sentry-go primitives (Hub, Scope, Event). Swapping to a different reporter later is a one-package change. Behavior is gated on the configured DSN (GONEXT_SENTRY_DSN env var, or Options.DSN). When unset the package installs a no-op tracker with zero overhead on the hot path; Shutdown is a no-op. When set, Init wires sentry-go with attach-stacktrace, AttachStacktrace=true, the build-info release, the config environment, and a hostname-derived ServerName. The returned Shutdown closer flushes the in-flight event queue with a 5s budget. Plugin-aware grouping: WithPluginSlug stamps a gonext.plugin.slug tag onto the event scope. Per-request middleware threads the slug on every plugin dispatch (in follow-up wiring); end-of-chain Capture calls inherit it via context, so events from plugin handlers group separately from host code in the Sentry dashboard. Wires the tracker into apps/api + apps/worker main.go with the Shutdown closer registered against the orchestrator — drains BEFORE the DB pool / Redis so events captured during shutdown still reach the ingestion endpoint over a live network. Setup failures are non-fatal (log a warning and continue without reporting), matching the existing tracing.Setup posture. Signed-off-by: Tayeb Mokni --- apps/api/cmd/server/main.go | 28 ++ apps/api/go.mod | 21 +- apps/api/go.sum | 54 ++- apps/worker/cmd/worker/main.go | 20 + apps/worker/go.mod | 20 +- apps/worker/go.sum | 57 ++- packages/go/go.mod | 3 +- packages/go/go.sum | 36 +- .../errortracker/errortracker.go | 352 ++++++++++++++++++ .../errortracker/errortracker_test.go | 238 ++++++++++++ 10 files changed, 786 insertions(+), 43 deletions(-) create mode 100644 packages/go/observability/errortracker/errortracker.go create mode 100644 packages/go/observability/errortracker/errortracker_test.go diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index c58ebf10..13f7b4e1 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -70,6 +70,7 @@ import ( "github.com/Singleton-Solution/GoNext/packages/go/media/collections" "github.com/Singleton-Solution/GoNext/packages/go/media/imgproxy" gonextmetrics "github.com/Singleton-Solution/GoNext/packages/go/metrics" + "github.com/Singleton-Solution/GoNext/packages/go/observability/errortracker" authmw "github.com/Singleton-Solution/GoNext/packages/go/middleware/auth" "github.com/Singleton-Solution/GoNext/packages/go/middleware/earlyhints" httpmetrics "github.com/Singleton-Solution/GoNext/packages/go/middleware/metrics" @@ -230,6 +231,33 @@ func run(ctx context.Context) error { return fmt.Errorf("theme seed: %w", seedErr) } + // Error tracking (#202). Sentry/GlitchTip wrapper that no-ops when + // GONEXT_SENTRY_DSN is unset. Wired BEFORE tracing + the HTTP + // server so any subsequent boot failure surfaces in the + // dashboard; registered with the orchestrator AFTER persistence + // so it drains BEFORE the DB pool / Redis (so an event captured + // during the drain still reaches the ingestion endpoint over a + // live network). + errTracker, errTrackerShutdown, errTrackerErr := errortracker.Init(errortracker.Options{ + Environment: string(cfg.Env), + Release: bi.Version, + ServerName: serviceName, + Logger: logger, + }) + if errTrackerErr != nil { + // Setup failure is non-fatal: the binary boots without the + // error tracker rather than refusing to start because of a + // misconfigured DSN. The warning surfaces in the boot log so + // operators notice. errTracker remains nil and the package's + // nil-receiver tolerance keeps downstream calls safe. + logger.Warn("errortracker: setup failed; continuing without error reporting", + slog.Any("err", errTrackerErr)) + } else { + orch.MustRegister(logger, "errortracker.client", + func(stopCtx context.Context) error { return errTrackerShutdown(stopCtx) }) + } + _ = errTracker // retained for the wiring layers that grow Capture call sites in follow-ups + // Distributed tracing (issue #186). The tracer provider is wired // AFTER the DB pool + Redis client (so its Shutdown drains // before they do — span exports may need outgoing HTTP and diff --git a/apps/api/go.mod b/apps/api/go.mod index 252e8a43..7f7e7ad7 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -39,13 +39,20 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/getsentry/sentry-go v0.43.0 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/webauthn v0.17.4 // indirect + github.com/go-webauthn/x v0.2.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect @@ -92,9 +99,15 @@ require ( github.com/testcontainers/testcontainers-go/modules/minio v0.42.0 // indirect github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 // indirect github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 // indirect - github.com/tinylib/msgp v1.6.1 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/wI2L/jsondiff v0.7.1 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -108,12 +121,12 @@ require ( go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.51.0 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/image v0.40.0 // indirect golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/net v0.54.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.44.0 // indirect diff --git a/apps/api/go.sum b/apps/api/go.sum index 343a66cc..632f3bbc 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -63,10 +63,18 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4= +github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -78,8 +86,14 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= +github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8= +github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= +github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -87,6 +101,10 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= @@ -160,6 +178,10 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -212,14 +234,28 @@ github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndr github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 h1:id/6LH8ZeDrtAUVSuNvZUAJ1kVpb82y1pr9yweAWsRg= github.com/testcontainers/testcontainers-go/modules/redis v0.42.0/go.mod h1:uF0jI8FITagQpBNOgweGBmPf6rP4K0SeL1XFPbsZSSY= -github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= -github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E= github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +github.com/wI2L/jsondiff v0.7.1 h1:Fg9+yj+1/x3UtPBJhR91TKEzRkrEEWcAcLbg9dzEaNM= +github.com/wI2L/jsondiff v0.7.1/go.mod h1:yAt2W7U6Jd4HK0RA8DGSGk0zDtfEtOUUJVnH/xICpjo= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= @@ -252,25 +288,27 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= diff --git a/apps/worker/cmd/worker/main.go b/apps/worker/cmd/worker/main.go index 4c30321b..c129cf76 100644 --- a/apps/worker/cmd/worker/main.go +++ b/apps/worker/cmd/worker/main.go @@ -33,6 +33,7 @@ import ( "github.com/Singleton-Solution/GoNext/packages/go/log" "github.com/Singleton-Solution/GoNext/packages/go/media/storage" "github.com/Singleton-Solution/GoNext/packages/go/metrics" + "github.com/Singleton-Solution/GoNext/packages/go/observability/errortracker" "github.com/Singleton-Solution/GoNext/packages/go/shutdown" ) @@ -90,6 +91,25 @@ func run(ctx context.Context) error { return fmt.Errorf("shutdown: %w", err) } + // Error tracking (#202). No-op when GONEXT_SENTRY_DSN is unset. + // Registered before the queue consumer so the consumer's drain + // can still report errors through a live transport. The cluster + // env name is the same one Asynq's dashboards filter on. + errTracker, errTrackerShutdown, errTrackerErr := errortracker.Init(errortracker.Options{ + Environment: string(cfg.Env), + Release: bi.Version, + ServerName: serviceName, + Logger: logger, + }) + if errTrackerErr != nil { + logger.Warn("errortracker: setup failed; continuing without error reporting", + "err", errTrackerErr.Error()) + } else { + orch.MustRegister(logger, "errortracker.client", + func(stopCtx context.Context) error { return errTrackerShutdown(stopCtx) }) + } + _ = errTracker // retained for task handlers that grow Capture sites in follow-ups + // Metrics registry. The worker's /metrics listener lives in a // follow-up issue (the dedicated port wiring already exists in // packages/go/metrics); for now we just need the registerer so the diff --git a/apps/worker/go.mod b/apps/worker/go.mod index 7e0d87a4..8c1c61b5 100644 --- a/apps/worker/go.mod +++ b/apps/worker/go.mod @@ -10,8 +10,18 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/getsentry/sentry-go v0.43.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/minio-go/v7 v7.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect @@ -19,10 +29,18 @@ require ( github.com/redis/go-redis/v9 v9.19.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/sys v0.44.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/apps/worker/go.sum b/apps/worker/go.sum index c2aa81a6..75c25610 100644 --- a/apps/worker/go.sum +++ b/apps/worker/go.sum @@ -34,12 +34,20 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4= +github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -54,8 +62,11 @@ github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw= github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -68,6 +79,12 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8= +github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= @@ -92,6 +109,12 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= @@ -110,6 +133,10 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -126,6 +153,8 @@ github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndr github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 h1:id/6LH8ZeDrtAUVSuNvZUAJ1kVpb82y1pr9yweAWsRg= github.com/testcontainers/testcontainers-go/modules/redis v0.42.0/go.mod h1:uF0jI8FITagQpBNOgweGBmPf6rP4K0SeL1XFPbsZSSY= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= @@ -134,28 +163,36 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/packages/go/go.mod b/packages/go/go.mod index 78f644fe..d0887b1e 100644 --- a/packages/go/go.mod +++ b/packages/go/go.mod @@ -9,6 +9,7 @@ require ( github.com/coreos/go-oidc/v3 v3.18.0 github.com/davidbyttow/govips/v2 v2.18.0 github.com/evanphx/json-patch/v5 v5.9.11 + github.com/getsentry/sentry-go v0.43.0 github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-webauthn/webauthn v0.17.4 github.com/golang-migrate/migrate/v4 v4.19.1 @@ -30,7 +31,6 @@ require ( github.com/tetratelabs/wazero v1.11.0 github.com/wI2L/jsondiff v0.7.1 go.opentelemetry.io/otel v1.43.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 golang.org/x/crypto v0.52.0 @@ -120,6 +120,7 @@ require ( github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect diff --git a/packages/go/go.sum b/packages/go/go.sum index 4befa2a5..d0566851 100644 --- a/packages/go/go.sum +++ b/packages/go/go.sum @@ -69,6 +69,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4= +github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -92,11 +96,15 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= @@ -166,6 +174,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -228,8 +238,6 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= -github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= @@ -252,26 +260,18 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= @@ -280,20 +280,19 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -302,8 +301,6 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= @@ -313,7 +310,8 @@ golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= diff --git a/packages/go/observability/errortracker/errortracker.go b/packages/go/observability/errortracker/errortracker.go new file mode 100644 index 00000000..5c1c22c6 --- /dev/null +++ b/packages/go/observability/errortracker/errortracker.go @@ -0,0 +1,352 @@ +// Package errortracker is a thin wrapper around getsentry/sentry-go that +// gives the GoNext binaries a single place to wire error reporting, +// flush on shutdown, and plugin-aware grouping. +// +// Contract: +// +// - When the configured DSN is empty (the default), the package +// installs a no-op tracker. Every Capture call returns immediately +// and the Shutdown closer is a no-op. The rest of the codebase +// calls into the package unconditionally; a partially-deployed +// observability rollout costs nothing in the unset-DSN case. +// +// - When the DSN is set (typically via GONEXT_SENTRY_DSN), the package +// calls sentry.Init with a sensible production default set +// (release = build version, environment = config env, server_name +// = service+hostname). The returned Shutdown drains the in-flight +// event queue via sentry.Flush. +// +// - Capture/CaptureMessage/Recover accept a ctx so the caller can +// thread plugin context through. WithPluginSlug stamps a +// gonext.plugin.slug tag onto the event; the host pipeline reads +// this tag for plugin-aware grouping (every event from +// plugin:acme-seo groups separately from host code) and the +// pre-built dashboards filter on it. +// +// The package deliberately does NOT expose sentry-go primitives (Hub, +// Scope, Event) — callers should be able to swap GlitchTip / a vendored +// reporter / a different SaaS without touching every handler. The +// surface here is: Init, Shutdown, Capture, CaptureMessage, Recover, +// WithPluginSlug. Anything more leaks into callers and locks us into +// sentry-go for the lifetime of the project. +// +// Issue #202. +package errortracker + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "time" + + "github.com/getsentry/sentry-go" +) + +// DSNEnv is the env var read when Options.DSN is empty. Exported so the +// `--print-config` dump can surface the active value alongside its +// sibling knobs (tracing.EndpointEnv, etc.). +const DSNEnv = "GONEXT_SENTRY_DSN" + +// TagPluginSlug is the tag key set by WithPluginSlug. Exported so +// dashboards and alert routing rules in the operations runbook can +// reference one source of truth. +const TagPluginSlug = "gonext.plugin.slug" + +// defaultFlushTimeout bounds the Shutdown flush. The Asynq worker's +// drain budget is 4 minutes and the API's is 30 seconds; either way, +// the error-tracker is a non-essential closer (we'd rather lose a +// handful of events than wedge the drain). 5 seconds is enough to +// flush a healthy queue against the Sentry SaaS over the public +// internet, and short enough to never noticeably slow shutdown. +const defaultFlushTimeout = 5 * time.Second + +// Options configures Init. Every field has a defaulting policy so an +// empty Options value is valid for "boot with the env-var-derived +// defaults". +type Options struct { + // DSN is the Sentry/GlitchTip ingest URL. When empty, Init reads + // the DSNEnv variable. When both are empty, the package installs + // the no-op tracker and Shutdown is a no-op. + DSN string + + // Environment populates the `environment` tag on every event. Set + // from cfg.Env so events filter by deployment tier in the + // dashboard. Required when DSN is set; empty fails Init validation + // to prevent silent "events with no environment" bugs. + Environment string + + // Release populates the `release` tag. Set from buildinfo.Version + // so events group by code revision; the binary that produced the + // event is identifiable without correlating with a separate + // deployment log. + Release string + + // ServerName populates the `server_name` tag. Typically the + // service name + os.Hostname() (e.g. "api@web-7d4f"); the package + // fills in os.Hostname() when this is empty. + ServerName string + + // SampleRate is the fraction of events to send. Defaults to 1.0 + // (every event). Operators tune this down during noisy incidents + // without redeploying — see docs/10-observability.md §6 for the + // runbook. + SampleRate float64 + + // FlushTimeout bounds the in-flight event queue drain in Shutdown. + // Defaults to defaultFlushTimeout. + FlushTimeout time.Duration + + // Logger receives one-line boot diagnostics and shutdown + // completion lines. Required — a misconfigured DSN should be + // loud, not silent. + Logger *slog.Logger + + // Debug enables sentry-go's debug stdout writer. Off by default; + // useful only when chasing a "did my event reach Sentry?" issue + // in a controlled environment. + Debug bool + + // initFunc is a seam for testing: the production caller leaves it + // nil and Init uses sentry.Init; tests inject a fake to verify + // the ClientOptions composition without standing up an HTTP + // transport. Unexported so the seam doesn't bleed into the + // public API. + initFunc func(sentry.ClientOptions) error +} + +// Tracker is the package's exported surface. One instance per binary; +// constructed by Init. Methods are safe for concurrent use. +// +// The no-op tracker (returned when DSN is empty) is a value-receiver +// pointer with enabled=false; every method short-circuits before any +// sentry-go call so there's zero overhead on the unset-DSN path. +type Tracker struct { + enabled bool + flushTimeout time.Duration + logger *slog.Logger +} + +// Init constructs the Tracker, installs the sentry-go global client +// when enabled, and returns a Shutdown closer. +// +// When opts.DSN (or the DSNEnv fallback) is empty, the returned +// Tracker is the no-op tracker and the Shutdown closer is a no-op. +// Production wiring registers the Shutdown closer with the shutdown +// orchestrator unconditionally — the no-op path costs nothing. +func Init(opts Options) (*Tracker, Shutdown, error) { + if opts.Logger == nil { + return nil, nil, errors.New("errortracker.Init: Logger is required") + } + if opts.FlushTimeout <= 0 { + opts.FlushTimeout = defaultFlushTimeout + } + + dsn := opts.DSN + if dsn == "" { + dsn = os.Getenv(DSNEnv) + } + if dsn == "" { + opts.Logger.Info("errortracker: disabled (DSN unset)") + return &Tracker{ + enabled: false, + flushTimeout: opts.FlushTimeout, + logger: opts.Logger, + }, noopShutdown, nil + } + + if opts.Environment == "" { + return nil, nil, errors.New("errortracker.Init: Environment is required when DSN is set") + } + if opts.SampleRate <= 0 { + opts.SampleRate = 1.0 + } + serverName := opts.ServerName + if serverName == "" { + // os.Hostname can fail on heavily-locked-down containers + // (CAP_SYS_ADMIN dropped, no /etc/hostname). We don't + // propagate the error — a missing server_name is a small loss + // compared to losing the entire error pipeline. + if h, err := os.Hostname(); err == nil { + serverName = h + } + } + + clientOpts := sentry.ClientOptions{ + Dsn: dsn, + Environment: opts.Environment, + Release: opts.Release, + ServerName: serverName, + SampleRate: opts.SampleRate, + Debug: opts.Debug, + // AttachStacktrace gives us a stacktrace on Capture(err) calls + // even when err is a plain errors.New — the cost is small + // (a runtime.Caller walk) and the value is large (one-click + // triage in the Sentry UI). Default is false in sentry-go; + // we deliberately flip it on. + AttachStacktrace: true, + } + + initFn := opts.initFunc + if initFn == nil { + initFn = sentry.Init + } + if err := initFn(clientOpts); err != nil { + return nil, nil, fmt.Errorf("errortracker.Init: sentry.Init: %w", err) + } + + opts.Logger.Info("errortracker: enabled", + slog.String("environment", opts.Environment), + slog.String("release", opts.Release), + slog.String("server_name", serverName), + slog.Float64("sample_rate", opts.SampleRate), + ) + + t := &Tracker{ + enabled: true, + flushTimeout: opts.FlushTimeout, + logger: opts.Logger, + } + return t, t.shutdown, nil +} + +// Shutdown is the function shape returned by Init. The caller hands it +// to the shutdown orchestrator so the in-flight event queue flushes +// before the binary exits. Idempotent. +type Shutdown func(context.Context) error + +func noopShutdown(_ context.Context) error { return nil } + +// shutdown flushes the sentry-go transport with the configured +// timeout. The ctx parameter is honored: if it's already canceled, we +// run a best-effort Flush against the original budget but log the +// truncation so operators see that we shed events. +func (t *Tracker) shutdown(ctx context.Context) error { + if !t.enabled { + return nil + } + budget := t.flushTimeout + if dl, ok := ctx.Deadline(); ok { + if remaining := time.Until(dl); remaining > 0 && remaining < budget { + budget = remaining + } + } + ok := sentry.Flush(budget) + if !ok { + t.logger.Warn("errortracker: flush timed out; some events may not have reached the ingestion endpoint", + slog.Duration("budget", budget), + ) + // We deliberately don't return an error: a slow Sentry must + // not fail the drain. The warning is sufficient to surface + // the issue. + } + return nil +} + +// Capture reports err to the configured tracker. Returns immediately +// on the no-op tracker or when err is nil. +// +// ctx may carry plugin context via WithPluginSlug; the tag is stamped +// on the event before send so dashboards group by plugin. +// +// Returns the Sentry event ID (empty when disabled or on send +// failure) so callers can correlate with the captured event in a +// user-facing error message. +func (t *Tracker) Capture(ctx context.Context, err error) string { + if t == nil || !t.enabled || err == nil { + return "" + } + hub := hubFromContext(ctx) + id := hub.CaptureException(err) + if id == nil { + return "" + } + return string(*id) +} + +// CaptureMessage reports a string message — useful for "this should +// never happen but didn't return an error" branches. Same semantics as +// Capture. +func (t *Tracker) CaptureMessage(ctx context.Context, msg string) string { + if t == nil || !t.enabled || msg == "" { + return "" + } + hub := hubFromContext(ctx) + id := hub.CaptureMessage(msg) + if id == nil { + return "" + } + return string(*id) +} + +// Recover is intended for deferred panic-recovery: pass it through +// `defer t.Recover(ctx, recover())`. Captures the panic value as a +// Sentry event without re-panicking. The handler/worker layer's own +// recovery middleware still owns the response handling — this is +// purely for surfacing the panic to the error tracker. +func (t *Tracker) Recover(ctx context.Context, r any) string { + if t == nil || !t.enabled || r == nil { + return "" + } + hub := hubFromContext(ctx) + id := hub.RecoverWithContext(ctx, r) + if id == nil { + return "" + } + return string(*id) +} + +// pluginSlugKey is the context key used by WithPluginSlug. Unexported +// to prevent collisions with any other context value defined under +// the same string — the standard pattern for context keys. +type pluginSlugKey struct{} + +// WithPluginSlug returns a context whose Capture/CaptureMessage/Recover +// events will be tagged with gonext.plugin.slug=slug. Call this at the +// entry point of every plugin handler — the host's plugin lifecycle +// middleware threads it on every dispatch, so end-of-chain Capture +// calls inherit the slug automatically. +// +// Passing an empty slug returns ctx unchanged. The fall-through case +// matters: a plugin call where the slug wasn't propagated should NOT +// retag with empty (which would create a "no plugin" tag) — it should +// inherit the parent context's tag, if any. +func WithPluginSlug(ctx context.Context, slug string) context.Context { + if slug == "" { + return ctx + } + return context.WithValue(ctx, pluginSlugKey{}, slug) +} + +// PluginSlugFromContext returns the slug attached by WithPluginSlug, +// or "" when none. Exported so middleware that builds a hub manually +// (e.g. an integration test) can read the value. +func PluginSlugFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + v, ok := ctx.Value(pluginSlugKey{}).(string) + if !ok { + return "" + } + return v +} + +// hubFromContext returns a sentry.Hub with the plugin slug applied as +// a tag (when present). We clone the current hub rather than mutating +// it — the current hub is process-global, and a slug-tagged scope +// must not leak across requests. +// +// Callers that don't carry plugin context get the bare CurrentHub +// which uses the same global scope as before. The branch keeps the +// hot path (no-plugin) allocation-free. +func hubFromContext(ctx context.Context) *sentry.Hub { + slug := PluginSlugFromContext(ctx) + if slug == "" { + return sentry.CurrentHub() + } + hub := sentry.CurrentHub().Clone() + hub.Scope().SetTag(TagPluginSlug, slug) + return hub +} diff --git a/packages/go/observability/errortracker/errortracker_test.go b/packages/go/observability/errortracker/errortracker_test.go new file mode 100644 index 00000000..c50eb517 --- /dev/null +++ b/packages/go/observability/errortracker/errortracker_test.go @@ -0,0 +1,238 @@ +package errortracker + +import ( + "context" + "errors" + "io" + "log/slog" + "strings" + "testing" + + "github.com/getsentry/sentry-go" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.NewJSONHandler(io.Discard, nil)) +} + +// TestInit_NoDSN_ReturnsNoopTracker verifies that the absence of a DSN +// (the common dev-loop and partial-rollout case) returns a disabled +// tracker whose Capture calls are no-ops and whose Shutdown closer +// returns nil immediately. +func TestInit_NoDSN_ReturnsNoopTracker(t *testing.T) { + t.Setenv(DSNEnv, "") + tracker, shutdown, err := Init(Options{ + Logger: discardLogger(), + }) + if err != nil { + t.Fatalf("Init: %v", err) + } + if tracker.enabled { + t.Fatal("expected disabled tracker when DSN unset") + } + // Capture on a disabled tracker returns "" and doesn't panic. + if id := tracker.Capture(context.Background(), errors.New("never reported")); id != "" { + t.Errorf("disabled Capture returned id %q; want empty", id) + } + if id := tracker.CaptureMessage(context.Background(), "msg"); id != "" { + t.Errorf("disabled CaptureMessage returned id %q; want empty", id) + } + if id := tracker.Recover(context.Background(), "oops"); id != "" { + t.Errorf("disabled Recover returned id %q; want empty", id) + } + // Shutdown should return nil with no flush attempted. + if err := shutdown(context.Background()); err != nil { + t.Errorf("Shutdown: %v", err) + } +} + +// TestInit_NilLogger_Errors documents the precondition. +func TestInit_NilLogger_Errors(t *testing.T) { + _, _, err := Init(Options{}) + if err == nil { + t.Fatal("expected error on nil logger") + } + if !strings.Contains(err.Error(), "Logger is required") { + t.Errorf("unexpected error: %v", err) + } +} + +// TestInit_DSNSet_RequiresEnvironment ensures we don't silently submit +// events with no `environment` tag — that creates a triage nightmare +// in the dashboard once the team has multiple deployments. +func TestInit_DSNSet_RequiresEnvironment(t *testing.T) { + _, _, err := Init(Options{ + DSN: "https://public@example.test/1", + Logger: discardLogger(), + }) + if err == nil { + t.Fatal("expected error on missing environment when DSN set") + } + if !strings.Contains(err.Error(), "Environment is required") { + t.Errorf("unexpected error: %v", err) + } +} + +// TestInit_DSNSet_CallsInit verifies the happy path by injecting a fake +// init function. We assert on the composed ClientOptions to confirm +// every advertised default landed. +func TestInit_DSNSet_CallsInit(t *testing.T) { + var captured sentry.ClientOptions + tracker, shutdown, err := Init(Options{ + DSN: "https://public@example.test/1", + Environment: "test", + Release: "v0.0.1", + ServerName: "test-server", + Logger: discardLogger(), + initFunc: func(o sentry.ClientOptions) error { + captured = o + return nil + }, + }) + if err != nil { + t.Fatalf("Init: %v", err) + } + if !tracker.enabled { + t.Fatal("expected enabled tracker when DSN set") + } + if captured.Dsn != "https://public@example.test/1" { + t.Errorf("DSN: got %q", captured.Dsn) + } + if captured.Environment != "test" { + t.Errorf("Environment: got %q", captured.Environment) + } + if captured.Release != "v0.0.1" { + t.Errorf("Release: got %q", captured.Release) + } + if captured.ServerName != "test-server" { + t.Errorf("ServerName: got %q", captured.ServerName) + } + if !captured.AttachStacktrace { + t.Error("AttachStacktrace should default to true") + } + if captured.SampleRate != 1.0 { + t.Errorf("SampleRate default: got %v want 1.0", captured.SampleRate) + } + if shutdown == nil { + t.Fatal("shutdown closer nil") + } +} + +// TestInit_DSNSet_HonorsCustomSampleRate confirms the sample-rate knob +// flows through. +func TestInit_DSNSet_HonorsCustomSampleRate(t *testing.T) { + var captured sentry.ClientOptions + _, _, err := Init(Options{ + DSN: "https://public@example.test/1", + Environment: "test", + SampleRate: 0.25, + Logger: discardLogger(), + initFunc: func(o sentry.ClientOptions) error { + captured = o + return nil + }, + }) + if err != nil { + t.Fatalf("Init: %v", err) + } + if captured.SampleRate != 0.25 { + t.Errorf("SampleRate: got %v want 0.25", captured.SampleRate) + } +} + +// TestInit_DSN_ReadFromEnv covers the operator-friendly path: the DSN +// is set via GONEXT_SENTRY_DSN and Init picks it up without a code +// change. +func TestInit_DSN_ReadFromEnv(t *testing.T) { + t.Setenv(DSNEnv, "https://envpub@example.test/2") + var captured sentry.ClientOptions + tracker, _, err := Init(Options{ + Environment: "staging", + Logger: discardLogger(), + initFunc: func(o sentry.ClientOptions) error { + captured = o + return nil + }, + }) + if err != nil { + t.Fatalf("Init: %v", err) + } + if !tracker.enabled { + t.Fatal("expected enabled tracker when env DSN set") + } + if captured.Dsn != "https://envpub@example.test/2" { + t.Errorf("env DSN: got %q", captured.Dsn) + } +} + +// TestInit_DefaultsServerNameFromHostname checks the fallback path. +func TestInit_DefaultsServerNameFromHostname(t *testing.T) { + var captured sentry.ClientOptions + _, _, err := Init(Options{ + DSN: "https://public@example.test/1", + Environment: "test", + Logger: discardLogger(), + initFunc: func(o sentry.ClientOptions) error { + captured = o + return nil + }, + }) + if err != nil { + t.Fatalf("Init: %v", err) + } + // We don't assert the exact hostname (CI environments differ), + // only that ServerName is non-empty when not specified. + if captured.ServerName == "" { + t.Error("ServerName should default to os.Hostname") + } +} + +// TestWithPluginSlug_AndRoundTrip exercises the context plumbing for +// plugin-aware grouping. +func TestWithPluginSlug_AndRoundTrip(t *testing.T) { + ctx := WithPluginSlug(context.Background(), "acme-seo") + if got := PluginSlugFromContext(ctx); got != "acme-seo" { + t.Errorf("PluginSlugFromContext: got %q want acme-seo", got) + } + + // Empty slug passes through unchanged. + unchanged := WithPluginSlug(ctx, "") + if got := PluginSlugFromContext(unchanged); got != "acme-seo" { + t.Errorf("empty slug should preserve parent: got %q", got) + } + + // No slug returns empty. + if got := PluginSlugFromContext(context.Background()); got != "" { + t.Errorf("missing slug: got %q want empty", got) + } + if got := PluginSlugFromContext(nil); got != "" { //nolint:staticcheck // explicit nil-ctx coverage + t.Errorf("nil ctx: got %q want empty", got) + } +} + +// TestTracker_NilReceiverSafe documents that callers don't have to +// nil-check before invoking — the package's no-op posture extends to +// the zero-value tracker case. +func TestTracker_NilReceiverSafe(t *testing.T) { + var tracker *Tracker + tracker.Capture(context.Background(), errors.New("x")) + tracker.CaptureMessage(context.Background(), "x") + tracker.Recover(context.Background(), "x") +} + +// TestHubFromContext_TagsScopeOnSluggedCtx verifies the integration +// between WithPluginSlug and the sentry-go scope. We can't reach into +// the scope's tags directly (sentry-go's Scope.Tags is private), but +// we can check that the returned hub is the cloned variant rather +// than the global one when a slug is present. +func TestHubFromContext_TagsScopeOnSluggedCtx(t *testing.T) { + base := sentry.CurrentHub() + noPlugin := hubFromContext(context.Background()) + if noPlugin != base { + t.Error("no-plugin ctx should return CurrentHub directly (no clone)") + } + plugin := hubFromContext(WithPluginSlug(context.Background(), "acme-seo")) + if plugin == base { + t.Error("plugin ctx should return a cloned hub, not CurrentHub") + } +}