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
2 changes: 1 addition & 1 deletion cmd/buggregator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ func main() {
}

// Build event service and inject into TCP modules.
eventService := httpserver.NewEventService(store, hub, registry, collector)
eventService := httpserver.NewEventService(store, hub, registry, collector, db)
if enabled.IsEnabled("monolog") {
monologMod.SetEventService(eventService)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func (a *App) Run() {
}

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

// Set up authentication.
authSettings := httpserver.AuthSettings{Enabled: a.cfg.Auth.Enabled}
Expand Down
2 changes: 1 addition & 1 deletion internal/server/http/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func setupAPI(t *testing.T) (*http.ServeMux, *storage.SQLiteStore) {
store := storage.NewSQLiteStore(db)
hub := ws.NewHub()
registry := module.NewRegistry()
es := serverhttp.NewEventService(store, hub, registry, nil)
es := serverhttp.NewEventService(store, hub, registry, nil, db)

mux := http.NewServeMux()
noopMiddleware := func(next http.Handler) http.Handler { return next }
Expand Down
5 changes: 3 additions & 2 deletions internal/server/http/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ func detectEventType(r *http.Request) *DetectedEvent {
}

// Method 3: SDK-specific headers that identify the event type.
isSentryStore := strings.HasSuffix(r.URL.Path, "/store") && !strings.Contains(r.URL.Path, "/profiler/")
if r.Header.Get("X-Sentry-Auth") != "" || strings.HasSuffix(r.URL.Path, "/envelope") || isSentryStore {
path := strings.TrimRight(r.URL.Path, "/")
isSentryStore := strings.HasSuffix(path, "/store") && !strings.Contains(path, "/profiler/")
if r.Header.Get("X-Sentry-Auth") != "" || strings.HasSuffix(path, "/envelope") || isSentryStore {
return &DetectedEvent{Type: "sentry"}
}
if r.Header.Get("X-Inspector-Key") != "" || r.Header.Get("X-Inspector-Version") != "" {
Expand Down
15 changes: 15 additions & 0 deletions internal/server/http/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,28 @@ func TestDetectEventType(t *testing.T) {
},
wantType: "sentry",
},
{
// Sentry PHP SDK 4.x posts to /api/{id}/envelope/ with a trailing slash.
name: "envelope path with trailing slash detects sentry",
makeRequest: func() *nethttp.Request {
return httptest.NewRequest("POST", "http://localhost/api/1/envelope/", nil)
},
wantType: "sentry",
},
{
name: "store path suffix detects sentry",
makeRequest: func() *nethttp.Request {
return httptest.NewRequest("POST", "http://localhost/api/1/store", nil)
},
wantType: "sentry",
},
{
name: "store path with trailing slash detects sentry",
makeRequest: func() *nethttp.Request {
return httptest.NewRequest("POST", "http://localhost/api/1/store/", nil)
},
wantType: "sentry",
},
{
name: "profiler store path does not detect sentry",
makeRequest: func() *nethttp.Request {
Expand Down
20 changes: 18 additions & 2 deletions internal/server/http/event_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package http

import (
"context"
"database/sql"
"log/slog"
"time"

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

func NewEventService(store event.Store, hub *ws.Hub, registry *module.Registry, m *metrics.Collector) *EventService {
return &EventService{store: store, hub: hub, registry: registry, metrics: m}
func NewEventService(store event.Store, hub *ws.Hub, registry *module.Registry, m *metrics.Collector, db *sql.DB) *EventService {
return &EventService{store: store, hub: hub, registry: registry, metrics: m, db: db}
}

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

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

ev := event.NewEvent(inc)

start := time.Now()
Expand Down
106 changes: 106 additions & 0 deletions internal/server/http/event_service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package http_test

import (
"context"
"testing"

"github.com/buggregator/go-buggregator/internal/event"
"github.com/buggregator/go-buggregator/internal/module"
serverhttp "github.com/buggregator/go-buggregator/internal/server/http"
"github.com/buggregator/go-buggregator/internal/server/ws"
"github.com/buggregator/go-buggregator/internal/storage"
)

func TestEventService_HandleIncoming_AutoRegistersProject(t *testing.T) {
db, err := storage.Open(":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })

// Run core migrations so the projects table exists with the default seed row.
migrator := storage.NewMigrator(db)
if err := migrator.AddFromFS("core", storage.CoreMigrations, "migrations"); err != nil {
t.Fatal(err)
}
if err := migrator.Run(); err != nil {
t.Fatal(err)
}

store := storage.NewSQLiteStore(db)
hub := ws.NewHub()
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".
inc := &event.Incoming{
UUID: "evt-1",
Type: "sentry",
Payload: []byte(`{"event_id":"evt-1","message":"boom"}`),
Project: "123",
}
if err := es.HandleIncoming(context.Background(), inc); err != nil {
t.Fatalf("HandleIncoming: %v", err)
}

// Project row must exist so the frontend can list/switch to it.
var name string
row := db.QueryRow(`SELECT name FROM projects WHERE key = ?`, "123")
if err := row.Scan(&name); err != nil {
t.Fatalf("project 123 was not registered: %v", err)
}

// Sending another event under the same key must not error (INSERT OR IGNORE).
inc2 := &event.Incoming{
UUID: "evt-2",
Type: "sentry",
Payload: []byte(`{"event_id":"evt-2"}`),
Project: "123",
}
if err := es.HandleIncoming(context.Background(), inc2); err != nil {
t.Fatalf("HandleIncoming (second event): %v", err)
}

var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM projects WHERE key = ?`, "123").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("project 123 row count = %d, want 1", count)
}
}

func TestEventService_HandleIncoming_DefaultProjectIsNotRewritten(t *testing.T) {
db, err := storage.Open(":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })

migrator := storage.NewMigrator(db)
if err := migrator.AddFromFS("core", storage.CoreMigrations, "migrations"); err != nil {
t.Fatal(err)
}
if err := migrator.Run(); err != nil {
t.Fatal(err)
}

// The seeded "default" project row has name "Default" — make sure the
// auto-register path doesn't overwrite it for vanilla events.
store := storage.NewSQLiteStore(db)
es := serverhttp.NewEventService(store, ws.NewHub(), module.NewRegistry(), nil, db)

inc := &event.Incoming{UUID: "evt-1", Type: "var-dump", Payload: []byte(`{}`)}
if err := es.HandleIncoming(context.Background(), inc); err != nil {
t.Fatal(err)
}

var name string
if err := db.QueryRow(`SELECT name FROM projects WHERE key = ?`, "default").Scan(&name); err != nil {
t.Fatal(err)
}
if name != "Default" {
t.Errorf("default project name = %q, want %q", name, "Default")
}
}
23 changes: 17 additions & 6 deletions internal/server/http/ingestion.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package http

import (
"encoding/json"
"io/fs"
"log/slog"
"net/http"
Expand Down Expand Up @@ -71,9 +72,7 @@ func (p *IngestionPipeline) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// If the event type was explicitly detected, stop the pipeline — don't
// fall through to lower-priority handlers like HTTP dump.
if detectedType != "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":true}`))
writeIngestionResponse(w, detectedType, "")
return
}
continue
Expand All @@ -89,9 +88,7 @@ func (p *IngestionPipeline) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":true}`))
writeIngestionResponse(w, incoming.Type, incoming.UUID)
return
}
}
Expand All @@ -103,3 +100,17 @@ func (p *IngestionPipeline) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
p.frontend.ServeHTTP(w, r)
}

// writeIngestionResponse writes an ingestion acknowledgement. Sentry SDKs
// inspect the body for {"id":"..."} to confirm successful delivery — returning
// {"status":true} works for most SDKs but trips some validators. Other modules
// stick with the legacy shape.
func writeIngestionResponse(w http.ResponseWriter, eventType, eventID string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if eventType == "sentry" {
_ = json.NewEncoder(w).Encode(map[string]string{"id": eventID})
return
}
_, _ = w.Write([]byte(`{"status":true}`))
}
Loading
Loading