Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions modules/sentry/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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 {
Expand Down
89 changes: 89 additions & 0 deletions modules/sentry/api_metrics.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
4 changes: 3 additions & 1 deletion modules/sentry/api_service_map.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
}
67 changes: 67 additions & 0 deletions modules/sentry/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
42 changes: 39 additions & 3 deletions modules/sentry/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)) {
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
39 changes: 39 additions & 0 deletions modules/sentry/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
32 changes: 32 additions & 0 deletions modules/sentry/storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package sentry
import (
"database/sql"
"encoding/json"
"strings"
"testing"

"github.com/buggregator/go-buggregator/internal/storage"
Expand Down Expand Up @@ -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{
Expand Down
Loading
Loading