From 552648bf901547d37b9e3a43e010ebb372a5ac4d Mon Sep 17 00:00:00 2001 From: Stephan Wentz Date: Fri, 16 Oct 2026 12:52:00 +0200 Subject: [PATCH] Sentry - store trace metrics instead of showing them as an empty event Envelope items of type trace_metric (Sentry PHP SDK 4.x traceMetrics(), JavaScript SDK metrics) fell through to the fallback that turns the first valid JSON item into a feed card, which showed up as an empty "Something went wrong" event. They are now stored in sentry_metrics, kept out of the event feed like logs, transactions and spans, listed under GET /api/sentry/metrics (filters: name, type, trace_id), counted in /api/sentry/counts and cleared with the other Sentry tables. Closes #347 Co-Authored-By: Claude Opus 5.5 --- modules/sentry/api.go | 2 + modules/sentry/api_metrics.go | 89 +++++++++++++++++++ modules/sentry/api_service_map.go | 4 +- modules/sentry/api_test.go | 67 ++++++++++++++ modules/sentry/handler.go | 42 ++++++++- modules/sentry/handler_test.go | 39 ++++++++ ..._01_000027_create_sentry_metrics_table.sql | 16 ++++ modules/sentry/storage_test.go | 32 +++++++ modules/sentry/store_metric.go | 62 +++++++++++++ modules/sentry/types.go | 18 ++++ 10 files changed, 367 insertions(+), 4 deletions(-) create mode 100644 modules/sentry/api_metrics.go create mode 100644 modules/sentry/migrations/2024_01_01_000027_create_sentry_metrics_table.sql create mode 100644 modules/sentry/store_metric.go diff --git a/modules/sentry/api.go b/modules/sentry/api.go index 296e005..0475662 100644 --- a/modules/sentry/api.go +++ b/modules/sentry/api.go @@ -13,6 +13,7 @@ func registerAPI(mux *http.ServeMux, db *sql.DB) { mux.HandleFunc("GET /api/sentry/traces", handleTracesList(db)) mux.HandleFunc("GET /api/sentry/traces/{traceId}", handleTraceDetail(db)) mux.HandleFunc("GET /api/sentry/logs", handleLogsList(db)) + mux.HandleFunc("GET /api/sentry/metrics", handleMetricsList(db)) mux.HandleFunc("GET /api/sentry/service-map", handleServiceMap(db)) mux.HandleFunc("GET /api/sentry/counts", handleCounts(db)) mux.HandleFunc("DELETE /api/sentry/all", handleClearAll(db)) @@ -30,6 +31,7 @@ func handleClearAll(db *sql.DB) http.HandlerFunc { "sentry_transactions", "sentry_traces", "sentry_logs", + "sentry_metrics", } for _, t := range tables { if _, err := db.Exec("DELETE FROM " + t); err != nil { diff --git a/modules/sentry/api_metrics.go b/modules/sentry/api_metrics.go new file mode 100644 index 0000000..a761f95 --- /dev/null +++ b/modules/sentry/api_metrics.go @@ -0,0 +1,89 @@ +package sentry + +import ( + "database/sql" + "encoding/json" + "net/http" + "strings" +) + +// handleMetricsList lists stored trace metrics, newest first. +// Filters: name, type (counter, gauge, distribution), trace_id. +func handleMetricsList(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + limit, offset := pagination(r, 100) + q := r.URL.Query() + + query := `SELECT id, trace_id, span_id, name, type, value, unit, attributes, metric_ts + FROM sentry_metrics` + countQuery := `SELECT COUNT(*) FROM sentry_metrics` + + var conditions []string + var args []any + + for _, filter := range []string{"name", "type", "trace_id"} { + if v := q.Get(filter); v != "" { + conditions = append(conditions, filter+" = ?") + args = append(args, v) + } + } + + where := "" + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") + } + + query += where + " ORDER BY metric_ts DESC LIMIT ? OFFSET ?" + countQuery += where + + var total int + db.QueryRow(countQuery, args...).Scan(&total) + + queryArgs := append(args, limit, offset) + rows, err := db.Query(query, queryArgs...) + if err != nil { + apiError(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var data []map[string]any + for rows.Next() { + var ( + id, name, metricType string + value float64 + traceID, spanID sql.NullString + unit sql.NullString + attrsStr sql.NullString + metricTS sql.NullString + ) + if err := rows.Scan(&id, &traceID, &spanID, &name, &metricType, &value, &unit, &attrsStr, &metricTS); err != nil { + apiError(w, err.Error(), http.StatusInternalServerError) + return + } + + var attrs any + if attrsStr.Valid { + json.Unmarshal([]byte(attrsStr.String), &attrs) + } + + data = append(data, map[string]any{ + "id": id, + "name": name, + "type": metricType, + "value": value, + "unit": scanNullString(unit), + "trace_id": scanNullString(traceID), + "span_id": scanNullString(spanID), + "metric_ts": scanNullString(metricTS), + "attributes": attrs, + }) + } + + if data == nil { + data = []map[string]any{} + } + + apiJSON(w, listResponse(data, total, currentPage(offset, limit), limit)) + } +} diff --git a/modules/sentry/api_service_map.go b/modules/sentry/api_service_map.go index e750287..0eb41d4 100644 --- a/modules/sentry/api_service_map.go +++ b/modules/sentry/api_service_map.go @@ -132,15 +132,17 @@ func labelForNode(address, opType string) string { func handleCounts(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var exceptions, traces, logs int + var exceptions, traces, logs, metrics int db.QueryRow(`SELECT COUNT(*) FROM sentry_error_events`).Scan(&exceptions) db.QueryRow(`SELECT COUNT(*) FROM sentry_traces`).Scan(&traces) db.QueryRow(`SELECT COUNT(*) FROM sentry_logs`).Scan(&logs) + db.QueryRow(`SELECT COUNT(*) FROM sentry_metrics`).Scan(&metrics) apiJSON(w, map[string]any{ "exceptions": exceptions, "traces": traces, "logs": logs, + "metrics": metrics, }) } } diff --git a/modules/sentry/api_test.go b/modules/sentry/api_test.go index bec6cd7..9553289 100644 --- a/modules/sentry/api_test.go +++ b/modules/sentry/api_test.go @@ -65,6 +65,13 @@ func seedTestData(t *testing.T, mux *http.ServeMux) { {TraceID: "trace-abc", Level: "warning", Body: "Slow query", SeverityNumber: 13}, {Level: "error", Body: "Connection refused", SeverityNumber: 17}, }) + + // Insert metrics. + storeMetrics(db, []MetricRecord{ + {TraceID: "trace-abc", Name: "http.client.duration", Type: "distribution", Value: 12.5, Unit: "millisecond", Timestamp: "1678272501"}, + {TraceID: "trace-abc", Name: "http.client.duration", Type: "distribution", Value: 40, Unit: "millisecond", Timestamp: "1678272502"}, + {Name: "order.status", Type: "gauge", Value: 3, Timestamp: "1678272503"}, + }) } func TestAPIExceptionsChronological(t *testing.T) { @@ -384,6 +391,63 @@ func TestAPILogsFilterByTraceID(t *testing.T) { } } +func TestAPIMetricsList(t *testing.T) { + mux := http.NewServeMux() + seedTestData(t, mux) + + req := httptest.NewRequest("GET", "/api/sentry/metrics", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("status = %d, want 200", w.Code) + } + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + + data := resp["data"].([]any) + if len(data) != 3 { + t.Fatalf("data length = %d, want 3", len(data)) + } + + newest := data[0].(map[string]any) + if newest["name"] != "order.status" || newest["type"] != "gauge" || newest["value"] != 3.0 { + t.Errorf("newest metric = %v, want the order.status gauge", newest) + } +} + +func TestAPIMetricsFilter(t *testing.T) { + mux := http.NewServeMux() + seedTestData(t, mux) + + tests := []struct { + query string + want int + }{ + {"name=http.client.duration", 2}, + {"type=gauge", 1}, + {"trace_id=trace-abc", 2}, + {"name=http.client.duration&type=gauge", 0}, + } + + for _, tt := range tests { + t.Run(tt.query, func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/sentry/metrics?"+tt.query, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + + data := resp["data"].([]any) + if len(data) != tt.want { + t.Errorf("data length = %d, want %d", len(data), tt.want) + } + }) + } +} + func TestAPIServiceMap(t *testing.T) { mux := http.NewServeMux() seedTestData(t, mux) @@ -435,6 +499,9 @@ func TestAPICounts(t *testing.T) { if int(resp["logs"].(float64)) != 3 { t.Errorf("logs = %v, want 3", resp["logs"]) } + if int(resp["metrics"].(float64)) != 3 { + t.Errorf("metrics = %v, want 3", resp["metrics"]) + } } func TestAPIEmptyDatabase(t *testing.T) { diff --git a/modules/sentry/handler.go b/modules/sentry/handler.go index 5fff42d..b772307 100644 --- a/modules/sentry/handler.go +++ b/modules/sentry/handler.go @@ -139,6 +139,9 @@ func (h *handler) handleEnvelope(body []byte, project string) (*event.Incoming, case "log": h.handleLogItem(item) + case "trace_metric": + h.handleMetricItem(item) + case "session", "sessions", "client_report", "metric_buckets", "check_in", "user_report", "attachment", "replay_event", "replay_recording", "statsd": // Silently discard known non-essential item types. @@ -154,11 +157,11 @@ func (h *handler) handleEnvelope(body []byte, project string) (*event.Incoming, } // If no primary payload was extracted, fall back to envelope wrapping. - // Skip transaction/spans/log items — they are stored in structured tables only. + // Skip structured-only item types — they are stored in their own tables. if primaryPayload == nil { primaryType = "sentry" for _, item := range items { - if item.Type == "transaction" || item.Type == "spans" || item.Type == "log" { + if isStructuredOnlyItem(item.Type) { continue } if json.Valid([]byte(item.Payload)) { @@ -169,7 +172,7 @@ func (h *handler) handleEnvelope(body []byte, project string) (*event.Incoming, } // If still no payload, the envelope only contained non-feed items - // (transactions, spans, logs). Don't create a canonical event. + // (transactions, spans, logs, metrics). Don't create a canonical event. if primaryPayload == nil { return nil, nil } @@ -292,6 +295,39 @@ func (h *handler) handleLogItem(item EnvelopeItem) { } } +// handleMetricItem processes a "trace_metric" envelope item. +func (h *handler) handleMetricItem(item EnvelopeItem) { + if h.db == nil { + return + } + + // Metric items can be {"items": [...]} or a bare array. + var envelope MetricEnvelope + if err := json.Unmarshal(item.Payload, &envelope); err == nil && len(envelope.Items) > 0 { + if err := storeMetrics(h.db, envelope.Items); err != nil { + slog.Warn("sentry: failed to store metrics", "err", err) + } + return + } + + var metrics []MetricRecord + if err := json.Unmarshal(item.Payload, &metrics); err == nil && len(metrics) > 0 { + if err := storeMetrics(h.db, metrics); err != nil { + slog.Warn("sentry: failed to store metrics (array)", "err", err) + } + } +} + +// isStructuredOnlyItem reports whether an envelope item type is stored only in +// structured tables and must never become a card in the global event feed. +func isStructuredOnlyItem(itemType string) bool { + switch itemType { + case "transaction", "spans", "log", "trace_metric": + return true + } + return false +} + // injectEventID adds "event_id" to a JSON payload that lacks it. // Used to normalize Sentry SDK v4+ payloads which omit event_id from item bodies. func injectEventID(payload json.RawMessage, eventID string) json.RawMessage { diff --git a/modules/sentry/handler_test.go b/modules/sentry/handler_test.go index 963b988..baea850 100644 --- a/modules/sentry/handler_test.go +++ b/modules/sentry/handler_test.go @@ -262,6 +262,45 @@ not valid json`) }) } +// traceMetricEnvelope is a "trace_metric" envelope as the Sentry PHP SDK 4.x sends it. +const traceMetricEnvelope = `{"event_id":"metric-envelope","sent_at":"2026-10-16T10:00:00Z"} +{"type":"trace_metric","item_count":2,"content_type":"application/vnd.sentry.items.trace-metric+json"} +{"items":[{"timestamp":1788420877.523564,"trace_id":"53c16245360049e1a0cd3041dede1ec7","span_id":"9ada5f708f96d465","name":"order.status","value":3,"unit":null,"type":"gauge","attributes":{"status":{"type":"string","value":"qa"}}},{"timestamp":1788420877.6,"trace_id":"53c16245360049e1a0cd3041dede1ec7","span_id":"9ada5f708f96d465","name":"http.client.duration","value":12.5,"unit":"millisecond","type":"distribution","attributes":{"server.address":{"type":"string","value":"api"}}}]} +` + +func TestHandleEnvelope_TraceMetrics(t *testing.T) { + t.Run("stored, not turned into a feed event", func(t *testing.T) { + db := setupTestDB(t) + h := &handler{db: db} + + inc, err := h.handleEnvelope([]byte(traceMetricEnvelope), "proj") + if err != nil { + t.Fatal(err) + } + if inc != nil { + t.Errorf("expected no feed event for a metrics-only envelope, got %+v", inc) + } + + var count int + db.QueryRow(`SELECT COUNT(*) FROM sentry_metrics`).Scan(&count) + if count != 2 { + t.Errorf("metric count = %d, want 2", count) + } + }) + + t.Run("dropped without a database", func(t *testing.T) { + h := &handler{} + + inc, err := h.handleEnvelope([]byte(traceMetricEnvelope), "proj") + if err != nil { + t.Fatal(err) + } + if inc != nil { + t.Errorf("expected no feed event for a metrics-only envelope, got %+v", inc) + } + }) +} + // TestHandler_NumericProject_Envelope routes a numeric DSN envelope to the // default project (empty project) — the JS SDK path of issue #338. func TestHandler_NumericProject_Envelope(t *testing.T) { diff --git a/modules/sentry/migrations/2024_01_01_000027_create_sentry_metrics_table.sql b/modules/sentry/migrations/2024_01_01_000027_create_sentry_metrics_table.sql new file mode 100644 index 0000000..b7635fc --- /dev/null +++ b/modules/sentry/migrations/2024_01_01_000027_create_sentry_metrics_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS sentry_metrics ( + id TEXT PRIMARY KEY, + trace_id TEXT, + span_id TEXT, + name TEXT NOT NULL, + type TEXT NOT NULL, + value REAL NOT NULL, + unit TEXT, + attributes TEXT, + metric_ts TEXT, + received_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_sentry_metrics_name ON sentry_metrics(name); +CREATE INDEX IF NOT EXISTS idx_sentry_metrics_trace ON sentry_metrics(trace_id) WHERE trace_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_sentry_metrics_ts ON sentry_metrics(metric_ts); diff --git a/modules/sentry/storage_test.go b/modules/sentry/storage_test.go index b41cf3f..4f530ab 100644 --- a/modules/sentry/storage_test.go +++ b/modules/sentry/storage_test.go @@ -3,6 +3,7 @@ package sentry import ( "database/sql" "encoding/json" + "strings" "testing" "github.com/buggregator/go-buggregator/internal/storage" @@ -320,6 +321,37 @@ func TestStoreLogs(t *testing.T) { } } +func TestStoreMetrics(t *testing.T) { + db := setupTestDB(t) + + metrics := []MetricRecord{ + {TraceID: "trace-1", SpanID: "span-1", Name: "http.client.duration", Type: "distribution", Value: 12.5, Unit: "millisecond", Timestamp: "1788420877.523564"}, + {Name: "order.status", Type: "gauge", Value: 3, Attributes: map[string]any{"status": map[string]any{"type": "string", "value": "qa"}}}, + {Type: "counter", Value: 1}, // no name: dropped + } + + if err := storeMetrics(db, metrics); err != nil { + t.Fatal(err) + } + + var count int + db.QueryRow(`SELECT COUNT(*) FROM sentry_metrics`).Scan(&count) + if count != 2 { + t.Errorf("metric count = %d, want 2", count) + } + + var traceID, metricType, unit, metricTS string + var value float64 + db.QueryRow(`SELECT trace_id, type, value, unit, metric_ts FROM sentry_metrics WHERE name = ?`, "http.client.duration"). + Scan(&traceID, &metricType, &value, &unit, &metricTS) + if traceID != "trace-1" || metricType != "distribution" || value != 12.5 || unit != "millisecond" { + t.Errorf("got trace_id=%q type=%q value=%v unit=%q", traceID, metricType, value, unit) + } + if !strings.HasPrefix(metricTS, "2026-09-03T07:34:37.5") { + t.Errorf("metric_ts = %q, want 2026-09-03T07:34:37.5…", metricTS) + } +} + func TestFingerprint(t *testing.T) { t.Run("exception event", func(t *testing.T) { ev := &ErrorEvent{ diff --git a/modules/sentry/store_metric.go b/modules/sentry/store_metric.go new file mode 100644 index 0000000..29e1f78 --- /dev/null +++ b/modules/sentry/store_metric.go @@ -0,0 +1,62 @@ +package sentry + +import ( + "database/sql" + "encoding/json" + "fmt" + + "github.com/buggregator/go-buggregator/internal/event" +) + +// storeMetrics batch inserts Sentry trace metric items. +func storeMetrics(db *sql.DB, metrics []MetricRecord) error { + if len(metrics) == 0 { + return nil + } + + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.Prepare( + `INSERT INTO sentry_metrics + (id, trace_id, span_id, name, type, value, unit, attributes, metric_ts) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + if err != nil { + return fmt.Errorf("prepare sentry_metrics: %w", err) + } + defer stmt.Close() + + for _, m := range metrics { + if m.Name == "" { + continue + } + + var attrs *string + if m.Attributes != nil { + b, _ := json.Marshal(m.Attributes) + s := string(b) + attrs = &s + } + + _, err = stmt.Exec( + event.GenerateUUID(), + nullIfEmpty(m.TraceID), + nullIfEmpty(m.SpanID), + m.Name, + m.Type, + m.Value, + nullIfEmpty(m.Unit), + attrs, + parseLogTimestamp(m.Timestamp), + ) + if err != nil { + return fmt.Errorf("insert sentry_metrics: %w", err) + } + } + + return tx.Commit() +} diff --git a/modules/sentry/types.go b/modules/sentry/types.go index 309a93a..b28d7a4 100644 --- a/modules/sentry/types.go +++ b/modules/sentry/types.go @@ -249,6 +249,24 @@ type LogRecord struct { Attributes map[string]any `json:"attributes"` } +// MetricEnvelope represents a Sentry "trace_metric" envelope item body. +type MetricEnvelope struct { + Items []MetricRecord `json:"items"` +} + +// MetricRecord represents a single Sentry trace metric: a counter, gauge or +// distribution value, linked to the trace and span it was recorded in. +type MetricRecord struct { + TraceID string `json:"trace_id"` + SpanID string `json:"span_id"` + Name string `json:"name"` + Type string `json:"type"` + Value float64 `json:"value"` + Unit string `json:"unit"` + Timestamp json.Number `json:"timestamp"` + Attributes map[string]any `json:"attributes"` +} + // effectiveMessage returns the event message, checking logentry fallback. func (e *ErrorEvent) effectiveMessage() string { if e.Message.Text != "" {