Skip to content
Merged
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
71 changes: 71 additions & 0 deletions e2e/tests/issue-338-numeric-project.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { test, expect } from '@playwright/test';

const BUGGREGATOR = 'http://localhost:8000';

// Regression for issue #338: the Sentry JS SDK forces a numeric DSN project id,
// which must land in the visible "default" project rather than an orphan project
// named after the id.
test.describe.configure({ mode: 'serial' });

async function sendSentryStore(eventId: string, value: string): Promise<void> {
const res = await fetch(`${BUGGREGATOR}/api/1/store`, {
method: 'POST',
headers: { 'X-Sentry-Auth': 'Sentry sentry_client=sentry.javascript.browser/8.0.0, sentry_key=abc' },
body: JSON.stringify({
event_id: eventId,
platform: 'javascript',
level: 'error',
exception: { values: [{ type: 'TypeError', value }] },
}),
});
expect(res.status).toBe(200);
}

async function sendSentryEnvelope(eventId: string, value: string): Promise<void> {
const body = [
JSON.stringify({ event_id: eventId }),
JSON.stringify({ type: 'event' }),
JSON.stringify({ event_id: eventId, platform: 'javascript', level: 'error', exception: { values: [{ type: 'ReferenceError', value }] } }),
].join('\n');
const res = await fetch(`${BUGGREGATOR}/api/2/envelope/`, {
method: 'POST',
headers: { 'X-Sentry-Auth': 'Sentry sentry_key=abc' },
body,
});
expect(res.status).toBe(200);
}

test.beforeAll(async () => {
await fetch(`${BUGGREGATOR}/api/events`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: '{}' }).catch(() => {});
await sendSentryStore('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'Issue 338 numeric DSN error (store)');
await sendSentryEnvelope('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'Issue 338 numeric DSN error (envelope)');
});

test('numeric-DSN Sentry events render in the default project feed', async ({ page }) => {
await page.goto(BUGGREGATOR);
await page.waitForLoadState('networkidle');

const card = page.locator('.preview-card').first();
await expect(card).toBeVisible({ timeout: 15_000 });

const feedText = (await page.locator('body').textContent()) ?? '';
expect(feedText.toLowerCase()).toContain('sentry');

await page.screenshot({ path: 'test-results/issue-338-default-feed.png', fullPage: true });
});

test('no orphan numeric project is created; events are stored under "default"', async () => {
const projects = await (await fetch(`${BUGGREGATOR}/api/projects`)).json();
const keys = (projects.data ?? []).map((p: { key: string }) => p.key);
expect(keys).toContain('default');
expect(keys).not.toContain('1');
expect(keys).not.toContain('2');

const events = await (await fetch(`${BUGGREGATOR}/api/events?project=default`)).json();
const ids = (events.data ?? []).map((e: { uuid: string }) => e.uuid);
expect(ids).toContain('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
expect(ids).toContain('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
for (const e of events.data ?? []) {
expect(e.project).toBe('default');
}
});
4 changes: 2 additions & 2 deletions internal/server/http/event_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ func TestEventService_HandleIncoming_AutoRegistersProject(t *testing.T) {
registry := module.NewRegistry()
es := serverhttp.NewEventService(store, hub, registry, nil, db)

// Simulate a Sentry event whose project key comes from the DSN path:
// /api/123/envelope/ → project "123".
// An event arriving with an explicit non-default project key (e.g. a named
// DSN path or an X-Buggregator-Project override) must auto-register that key.
inc := &event.Incoming{
UUID: "evt-1",
Type: "sentry",
Expand Down
152 changes: 145 additions & 7 deletions internal/server/http/ingestion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package http_test

import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -617,10 +618,10 @@ func TestPipeline_NonSentryResponse_KeepsLegacyShape(t *testing.T) {
}
}

// TestPipeline_AutoCreatesSentryProject ensures the project key embedded in a
// Sentry DSN path (e.g. /api/12345/envelope/ → "12345") becomes a real row in
// the projects table, otherwise the frontend filters the event out of its list.
func TestPipeline_AutoCreatesSentryProject(t *testing.T) {
// newSentryPipeline builds a full ingestion pipeline backed by an in-memory DB
// with the Sentry module registered, for end-to-end project-routing tests.
func newSentryPipeline(t *testing.T) (*serverhttp.IngestionPipeline, *sql.DB) {
t.Helper()
db, err := storage.Open(":memory:")
if err != nil {
t.Fatal(err)
Expand All @@ -643,12 +644,21 @@ func TestPipeline_AutoCreatesSentryProject(t *testing.T) {
hub := ws.NewHub()
es := serverhttp.NewEventService(store, hub, registry, nil, db)
pipeline := serverhttp.NewIngestionPipeline(registry.Handlers(), es, fstest.MapFS{"index.html": {Data: []byte("<html></html>")}})
return pipeline, db
}

// TestPipeline_AutoCreatesSentryProject ensures a NAMED project key embedded in a
// Sentry DSN path (e.g. /api/myteam/envelope/ → "myteam") becomes a real row in
// the projects table, otherwise the frontend filters the event out of its list.
// (Numeric ids route to the default project instead — see the test below.)
func TestPipeline_AutoCreatesSentryProject(t *testing.T) {
pipeline, db := newSentryPipeline(t)

body := `{"event_id":"sx-1","sent_at":"2026-01-01T00:00:00Z"}
{"type":"event"}
{"event_id":"sx-1","level":"error","message":"x","exception":{"values":[{"type":"E","value":"v"}]}}`

r := httptest.NewRequest("POST", "http://localhost/api/12345/envelope/", strings.NewReader(body))
r := httptest.NewRequest("POST", "http://localhost/api/myteam/envelope/", strings.NewReader(body))
r.Header.Set("X-Sentry-Auth", "Sentry sentry_key=abc")
w := httptest.NewRecorder()
pipeline.ServeHTTP(w, r)
Expand All @@ -658,8 +668,136 @@ func TestPipeline_AutoCreatesSentryProject(t *testing.T) {
}

var name string
if err := db.QueryRow(`SELECT name FROM projects WHERE key = ?`, "12345").Scan(&name); err != nil {
t.Fatalf("project 12345 was not auto-registered: %v", err)
if err := db.QueryRow(`SELECT name FROM projects WHERE key = ?`, "myteam").Scan(&name); err != nil {
t.Fatalf("project myteam was not auto-registered: %v", err)
}
}

// TestPipeline_SentryNumericProjectRoutesToDefault is the regression test for
// issue #338: the Sentry JS SDK forces a numeric DSN project id, which must land
// in the default project rather than creating an orphan project named after the id.
func TestPipeline_SentryNumericProjectRoutesToDefault(t *testing.T) {
pipeline, db := newSentryPipeline(t)

payload := `{"event_id":"8f78970685be481994063baacda75197","timestamp":1774960590.323397,"level":"error","exception":{"values":[{"type":"E","value":"v"}]}}`
r := httptest.NewRequest("POST", "http://localhost/api/1/store", strings.NewReader(payload))
r.Header.Set("X-Sentry-Auth", "Sentry sentry_key=abc")
w := httptest.NewRecorder()
pipeline.ServeHTTP(w, r)

if w.Code != 200 {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}

var orphan int
if err := db.QueryRow(`SELECT COUNT(*) FROM projects WHERE key = ?`, "1").Scan(&orphan); err != nil {
t.Fatal(err)
}
if orphan != 0 {
t.Errorf("orphan project %q was created, want none", "1")
}

var project string
if err := db.QueryRow(`SELECT project FROM events WHERE uuid = ?`, "8f78970685be481994063baacda75197").Scan(&project); err != nil {
t.Fatalf("event was not stored: %v", err)
}
if project != "default" {
t.Errorf("event project = %q, want %q", project, "default")
}
}

// TestPipeline_SentryNumericProjectEnvelopeRoutesToDefault is the envelope-path
// counterpart of the regression above — the path the Sentry JS SDK actually uses.
func TestPipeline_SentryNumericProjectEnvelopeRoutesToDefault(t *testing.T) {
pipeline, db := newSentryPipeline(t)

body := `{"event_id":"env-err-1","sent_at":"2026-01-01T00:00:00Z"}
{"type":"event"}
{"event_id":"env-err-1","level":"error","message":"x","exception":{"values":[{"type":"E","value":"v"}]}}`
r := httptest.NewRequest("POST", "http://localhost/api/1/envelope/", strings.NewReader(body))
r.Header.Set("X-Sentry-Auth", "Sentry sentry_key=abc")
w := httptest.NewRecorder()
pipeline.ServeHTTP(w, r)

if w.Code != 200 {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}

var orphan int
if err := db.QueryRow(`SELECT COUNT(*) FROM projects WHERE key = ?`, "1").Scan(&orphan); err != nil {
t.Fatal(err)
}
if orphan != 0 {
t.Errorf("orphan project %q was created, want none", "1")
}

var project string
if err := db.QueryRow(`SELECT project FROM events WHERE uuid = ?`, "env-err-1").Scan(&project); err != nil {
t.Fatalf("event was not stored: %v", err)
}
if project != "default" {
t.Errorf("event project = %q, want %q", project, "default")
}
}

// TestPipeline_SentryProjectHeaderOverridesNumeric confirms an explicit
// X-Buggregator-Project still wins over numeric-resolution: the escape hatch for
// directing a numeric DSN to a named project rather than to default.
func TestPipeline_SentryProjectHeaderOverridesNumeric(t *testing.T) {
pipeline, db := newSentryPipeline(t)

payload := `{"event_id":"ovr-1","timestamp":1774960590.32,"level":"error","exception":{"values":[{"type":"E","value":"v"}]}}`
r := httptest.NewRequest("POST", "http://localhost/api/1/store", strings.NewReader(payload))
r.Header.Set("X-Buggregator-Event", "sentry")
r.Header.Set("X-Buggregator-Project", "web")
w := httptest.NewRecorder()
pipeline.ServeHTTP(w, r)

if w.Code != 200 {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}

var project string
if err := db.QueryRow(`SELECT project FROM events WHERE uuid = ?`, "ovr-1").Scan(&project); err != nil {
t.Fatalf("event was not stored: %v", err)
}
if project != "web" {
t.Errorf("event project = %q, want %q", project, "web")
}

var orphan int
if err := db.QueryRow(`SELECT COUNT(*) FROM projects WHERE key = ?`, "1").Scan(&orphan); err != nil {
t.Fatal(err)
}
if orphan != 0 {
t.Errorf("orphan project %q was created, want none", "1")
}
}

// TestPipeline_SentryTransactionNumericProject_NoOrphanProject confirms a
// transaction-only envelope on a numeric DSN creates no orphan project (it
// produces no canonical event, so the auto-register path is never reached).
func TestPipeline_SentryTransactionNumericProject_NoOrphanProject(t *testing.T) {
pipeline, db := newSentryPipeline(t)

body := `{"event_id":"tx-1","sent_at":"2026-01-01T00:00:00Z"}
{"type":"transaction"}
{"event_id":"tx-1","type":"transaction","transaction":"GET /","start_timestamp":1,"timestamp":2,"spans":[]}`
r := httptest.NewRequest("POST", "http://localhost/api/1/envelope/", strings.NewReader(body))
r.Header.Set("X-Sentry-Auth", "Sentry sentry_key=abc")
w := httptest.NewRecorder()
pipeline.ServeHTTP(w, r)

if w.Code != 200 {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}

var orphan int
if err := db.QueryRow(`SELECT COUNT(*) FROM projects WHERE key = ?`, "1").Scan(&orphan); err != nil {
t.Fatal(err)
}
if orphan != 0 {
t.Errorf("orphan project %q was created by a transaction envelope, want none", "1")
}
}

Expand Down
30 changes: 15 additions & 15 deletions modules/sentry/api_exceptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,19 +211,21 @@ func handleExceptionDetail(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")

// Load error event.
// Load error event. The URL segment may be an internal id or a Sentry
// event_id (the exceptions list and the frontend route both key on
// event_id), so resolve by either and prefer the internal-id match.
var (
eventID, fingerprint, level, receivedAt, payloadStr string
handled sql.NullBool
platform, environment, serverName, txn, release sql.NullString
traceID, spanID sql.NullString
eventTS sql.NullString
internalID, eventID, fingerprint, level, receivedAt, payloadStr string
handled sql.NullBool
platform, environment, serverName, txn, release sql.NullString
traceID, spanID sql.NullString
eventTS sql.NullString
)
err := db.QueryRow(
`SELECT event_id, fingerprint, level, handled, platform, environment, server_name,
`SELECT id, event_id, fingerprint, level, handled, platform, environment, server_name,
"transaction", release, trace_id, span_id, received_at, event_ts, payload
FROM sentry_error_events WHERE id = ?`, id,
).Scan(&eventID, &fingerprint, &level, &handled, &platform, &environment,
FROM sentry_error_events WHERE id = ? OR event_id = ? ORDER BY (id = ?) DESC LIMIT 1`, id, id, id,
).Scan(&internalID, &eventID, &fingerprint, &level, &handled, &platform, &environment,
&serverName, &txn, &release, &traceID, &spanID, &receivedAt, &eventTS, &payloadStr)
if err == sql.ErrNoRows {
apiError(w, "not found", http.StatusNotFound)
Expand All @@ -234,11 +236,9 @@ func handleExceptionDetail(db *sql.DB) http.HandlerFunc {
return
}

// Load exceptions.
exceptions := loadExceptions(db, id)

// Load breadcrumbs.
breadcrumbs := loadBreadcrumbs(db, id)
// Child tables key on the internal id, not the URL segment.
exceptions := loadExceptions(db, internalID)
breadcrumbs := loadBreadcrumbs(db, internalID)

// Load trace summary if trace_id exists.
var traceSummary any
Expand All @@ -253,7 +253,7 @@ func handleExceptionDetail(db *sql.DB) http.HandlerFunc {
}

result := map[string]any{
"id": id,
"id": internalID,
"event_id": eventID,
"fingerprint": fingerprint,
"level": level,
Expand Down
37 changes: 37 additions & 0 deletions modules/sentry/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,43 @@ func TestAPIExceptionDetail(t *testing.T) {
}
}

// TestAPIExceptionDetailByEventID covers Fix B: the detail endpoint must resolve
// by event_id (the key the list and the frontend route use) and still return the
// populated exceptions/breadcrumbs keyed off the internal id.
func TestAPIExceptionDetailByEventID(t *testing.T) {
mux := http.NewServeMux()
seedTestData(t, mux)

req := httptest.NewRequest("GET", "/api/sentry/exceptions", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)

var listResp map[string]any
json.Unmarshal(w.Body.Bytes(), &listResp)
first := listResp["data"].([]any)[0].(map[string]any)
eventID := first["event_id"].(string)
internalID := first["id"].(string)

req = httptest.NewRequest("GET", "/api/sentry/exceptions/"+eventID, nil)
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)

if w.Code != 200 {
t.Fatalf("status = %d, want 200 for event_id lookup", w.Code)
}

var detail map[string]any
json.Unmarshal(w.Body.Bytes(), &detail)

if detail["id"] != internalID {
t.Errorf("detail id = %v, want internal id %q", detail["id"], internalID)
}
excs, ok := detail["exceptions"].([]any)
if !ok || len(excs) == 0 {
t.Errorf("exceptions empty for event_id lookup, want populated (got %v)", detail["exceptions"])
}
}

func TestAPIExceptionDetailNotFound(t *testing.T) {
mux := http.NewServeMux()
seedTestData(t, mux)
Expand Down
Loading
Loading