Skip to content

Commit 7efb3fe

Browse files
committed
fix: route Sentry events into a frontend-visible project and harden envelope parsing
Sentry events arrived under the Sentry project ID parsed from the DSN path, which was never registered as a buggregator project — the frontend filtered them out of the events list and the sidebar dot stayed unlit. EventService now INSERT OR IGNORE's unknown project keys on ingest, the envelope parser honors per-item length headers (multi-line payloads), trailing slashes on /envelope/ and /store/ are recognised, and Sentry SDKs receive {id: event_id} on a successful ingest. Issue #321.
1 parent f1b55d9 commit 7efb3fe

13 files changed

Lines changed: 530 additions & 56 deletions

File tree

‎cmd/buggregator/main.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ func main() {
144144
}
145145

146146
// Build event service and inject into TCP modules.
147-
eventService := httpserver.NewEventService(store, hub, registry, collector)
147+
eventService := httpserver.NewEventService(store, hub, registry, collector, db)
148148
if enabled.IsEnabled("monolog") {
149149
monologMod.SetEventService(eventService)
150150
}

‎internal/app/app.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ func (a *App) Run() {
6666
}
6767

6868
// Build the event service that ties ingestion -> store -> broadcast.
69-
eventService := httpserver.NewEventService(store, a.hub, a.registry, a.metrics)
69+
eventService := httpserver.NewEventService(store, a.hub, a.registry, a.metrics, a.db)
7070

7171
// Set up authentication.
7272
authSettings := httpserver.AuthSettings{Enabled: a.cfg.Auth.Enabled}

‎internal/server/http/api_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func setupAPI(t *testing.T) (*http.ServeMux, *storage.SQLiteStore) {
3939
store := storage.NewSQLiteStore(db)
4040
hub := ws.NewHub()
4141
registry := module.NewRegistry()
42-
es := serverhttp.NewEventService(store, hub, registry, nil)
42+
es := serverhttp.NewEventService(store, hub, registry, nil, db)
4343

4444
mux := http.NewServeMux()
4545
noopMiddleware := func(next http.Handler) http.Handler { return next }

‎internal/server/http/detect.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ func detectEventType(r *http.Request) *DetectedEvent {
3737
}
3838

3939
// Method 3: SDK-specific headers that identify the event type.
40-
isSentryStore := strings.HasSuffix(r.URL.Path, "/store") && !strings.Contains(r.URL.Path, "/profiler/")
41-
if r.Header.Get("X-Sentry-Auth") != "" || strings.HasSuffix(r.URL.Path, "/envelope") || isSentryStore {
40+
path := strings.TrimRight(r.URL.Path, "/")
41+
isSentryStore := strings.HasSuffix(path, "/store") && !strings.Contains(path, "/profiler/")
42+
if r.Header.Get("X-Sentry-Auth") != "" || strings.HasSuffix(path, "/envelope") || isSentryStore {
4243
return &DetectedEvent{Type: "sentry"}
4344
}
4445
if r.Header.Get("X-Inspector-Key") != "" || r.Header.Get("X-Inspector-Version") != "" {

‎internal/server/http/detect_test.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,28 @@ func TestDetectEventType(t *testing.T) {
109109
},
110110
wantType: "sentry",
111111
},
112+
{
113+
// Sentry PHP SDK 4.x posts to /api/{id}/envelope/ with a trailing slash.
114+
name: "envelope path with trailing slash detects sentry",
115+
makeRequest: func() *nethttp.Request {
116+
return httptest.NewRequest("POST", "http://localhost/api/1/envelope/", nil)
117+
},
118+
wantType: "sentry",
119+
},
112120
{
113121
name: "store path suffix detects sentry",
114122
makeRequest: func() *nethttp.Request {
115123
return httptest.NewRequest("POST", "http://localhost/api/1/store", nil)
116124
},
117125
wantType: "sentry",
118126
},
127+
{
128+
name: "store path with trailing slash detects sentry",
129+
makeRequest: func() *nethttp.Request {
130+
return httptest.NewRequest("POST", "http://localhost/api/1/store/", nil)
131+
},
132+
wantType: "sentry",
133+
},
119134
{
120135
name: "profiler store path does not detect sentry",
121136
makeRequest: func() *nethttp.Request {

‎internal/server/http/event_service.go‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package http
22

33
import (
44
"context"
5+
"database/sql"
56
"log/slog"
67
"time"
78

@@ -19,10 +20,11 @@ type EventService struct {
1920
hub *ws.Hub
2021
registry *module.Registry
2122
metrics *metrics.Collector
23+
db *sql.DB // optional; used to auto-register new project keys
2224
}
2325

24-
func NewEventService(store event.Store, hub *ws.Hub, registry *module.Registry, m *metrics.Collector) *EventService {
25-
return &EventService{store: store, hub: hub, registry: registry, metrics: m}
26+
func NewEventService(store event.Store, hub *ws.Hub, registry *module.Registry, m *metrics.Collector, db *sql.DB) *EventService {
27+
return &EventService{store: store, hub: hub, registry: registry, metrics: m, db: db}
2628
}
2729

2830
// HandleIncoming stores an event, broadcasts preview, and notifies modules.
@@ -32,6 +34,20 @@ func (s *EventService) HandleIncoming(ctx context.Context, inc *event.Incoming)
3234
inc.Project = defaultProject
3335
}
3436

37+
// Auto-register the project so the frontend can see and switch to it.
38+
// Sentry events arrive with a project key derived from the DSN path
39+
// (e.g. /api/123/envelope/ → "123"), which won't exist in the projects
40+
// table seeded only with "default". Without this row, the frontend filters
41+
// the event out of the events list and the sidebar dot stays unlit.
42+
if s.db != nil && inc.Project != defaultProject {
43+
if _, err := s.db.ExecContext(ctx,
44+
`INSERT OR IGNORE INTO projects (key, name) VALUES (?, ?)`,
45+
inc.Project, inc.Project,
46+
); err != nil {
47+
slog.Warn("failed to register project", "key", inc.Project, "err", err)
48+
}
49+
}
50+
3551
ev := event.NewEvent(inc)
3652

3753
start := time.Now()
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package http_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/buggregator/go-buggregator/internal/event"
8+
"github.com/buggregator/go-buggregator/internal/module"
9+
serverhttp "github.com/buggregator/go-buggregator/internal/server/http"
10+
"github.com/buggregator/go-buggregator/internal/server/ws"
11+
"github.com/buggregator/go-buggregator/internal/storage"
12+
)
13+
14+
func TestEventService_HandleIncoming_AutoRegistersProject(t *testing.T) {
15+
db, err := storage.Open(":memory:")
16+
if err != nil {
17+
t.Fatal(err)
18+
}
19+
t.Cleanup(func() { db.Close() })
20+
21+
// Run core migrations so the projects table exists with the default seed row.
22+
migrator := storage.NewMigrator(db)
23+
if err := migrator.AddFromFS("core", storage.CoreMigrations, "migrations"); err != nil {
24+
t.Fatal(err)
25+
}
26+
if err := migrator.Run(); err != nil {
27+
t.Fatal(err)
28+
}
29+
30+
store := storage.NewSQLiteStore(db)
31+
hub := ws.NewHub()
32+
registry := module.NewRegistry()
33+
es := serverhttp.NewEventService(store, hub, registry, nil, db)
34+
35+
// Simulate a Sentry event whose project key comes from the DSN path:
36+
// /api/123/envelope/ → project "123".
37+
inc := &event.Incoming{
38+
UUID: "evt-1",
39+
Type: "sentry",
40+
Payload: []byte(`{"event_id":"evt-1","message":"boom"}`),
41+
Project: "123",
42+
}
43+
if err := es.HandleIncoming(context.Background(), inc); err != nil {
44+
t.Fatalf("HandleIncoming: %v", err)
45+
}
46+
47+
// Project row must exist so the frontend can list/switch to it.
48+
var name string
49+
row := db.QueryRow(`SELECT name FROM projects WHERE key = ?`, "123")
50+
if err := row.Scan(&name); err != nil {
51+
t.Fatalf("project 123 was not registered: %v", err)
52+
}
53+
54+
// Sending another event under the same key must not error (INSERT OR IGNORE).
55+
inc2 := &event.Incoming{
56+
UUID: "evt-2",
57+
Type: "sentry",
58+
Payload: []byte(`{"event_id":"evt-2"}`),
59+
Project: "123",
60+
}
61+
if err := es.HandleIncoming(context.Background(), inc2); err != nil {
62+
t.Fatalf("HandleIncoming (second event): %v", err)
63+
}
64+
65+
var count int
66+
if err := db.QueryRow(`SELECT COUNT(*) FROM projects WHERE key = ?`, "123").Scan(&count); err != nil {
67+
t.Fatal(err)
68+
}
69+
if count != 1 {
70+
t.Errorf("project 123 row count = %d, want 1", count)
71+
}
72+
}
73+
74+
func TestEventService_HandleIncoming_DefaultProjectIsNotRewritten(t *testing.T) {
75+
db, err := storage.Open(":memory:")
76+
if err != nil {
77+
t.Fatal(err)
78+
}
79+
t.Cleanup(func() { db.Close() })
80+
81+
migrator := storage.NewMigrator(db)
82+
if err := migrator.AddFromFS("core", storage.CoreMigrations, "migrations"); err != nil {
83+
t.Fatal(err)
84+
}
85+
if err := migrator.Run(); err != nil {
86+
t.Fatal(err)
87+
}
88+
89+
// The seeded "default" project row has name "Default" — make sure the
90+
// auto-register path doesn't overwrite it for vanilla events.
91+
store := storage.NewSQLiteStore(db)
92+
es := serverhttp.NewEventService(store, ws.NewHub(), module.NewRegistry(), nil, db)
93+
94+
inc := &event.Incoming{UUID: "evt-1", Type: "var-dump", Payload: []byte(`{}`)}
95+
if err := es.HandleIncoming(context.Background(), inc); err != nil {
96+
t.Fatal(err)
97+
}
98+
99+
var name string
100+
if err := db.QueryRow(`SELECT name FROM projects WHERE key = ?`, "default").Scan(&name); err != nil {
101+
t.Fatal(err)
102+
}
103+
if name != "Default" {
104+
t.Errorf("default project name = %q, want %q", name, "Default")
105+
}
106+
}

‎internal/server/http/ingestion.go‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package http
22

33
import (
4+
"encoding/json"
45
"io/fs"
56
"log/slog"
67
"net/http"
@@ -71,9 +72,7 @@ func (p *IngestionPipeline) ServeHTTP(w http.ResponseWriter, r *http.Request) {
7172
// If the event type was explicitly detected, stop the pipeline — don't
7273
// fall through to lower-priority handlers like HTTP dump.
7374
if detectedType != "" {
74-
w.Header().Set("Content-Type", "application/json")
75-
w.WriteHeader(http.StatusOK)
76-
w.Write([]byte(`{"status":true}`))
75+
writeIngestionResponse(w, detectedType, "")
7776
return
7877
}
7978
continue
@@ -89,9 +88,7 @@ func (p *IngestionPipeline) ServeHTTP(w http.ResponseWriter, r *http.Request) {
8988
return
9089
}
9190

92-
w.Header().Set("Content-Type", "application/json")
93-
w.WriteHeader(http.StatusOK)
94-
w.Write([]byte(`{"status":true}`))
91+
writeIngestionResponse(w, incoming.Type, incoming.UUID)
9592
return
9693
}
9794
}
@@ -103,3 +100,17 @@ func (p *IngestionPipeline) ServeHTTP(w http.ResponseWriter, r *http.Request) {
103100
}
104101
p.frontend.ServeHTTP(w, r)
105102
}
103+
104+
// writeIngestionResponse writes an ingestion acknowledgement. Sentry SDKs
105+
// inspect the body for {"id":"..."} to confirm successful delivery — returning
106+
// {"status":true} works for most SDKs but trips some validators. Other modules
107+
// stick with the legacy shape.
108+
func writeIngestionResponse(w http.ResponseWriter, eventType, eventID string) {
109+
w.Header().Set("Content-Type", "application/json")
110+
w.WriteHeader(http.StatusOK)
111+
if eventType == "sentry" {
112+
_ = json.NewEncoder(w).Encode(map[string]string{"id": eventID})
113+
return
114+
}
115+
_, _ = w.Write([]byte(`{"status":true}`))
116+
}

0 commit comments

Comments
 (0)