diff --git a/README.md b/README.md index 59a989d8d..0105c11f0 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ The SDK supports reporting errors and tracking application performance. To get started, have a look at one of our [examples](_examples/): - [Basic error instrumentation](_examples/basic/main.go) - [Error and tracing for HTTP servers](_examples/http/main.go) +- [Local development debugging with Spotlight](_examples/spotlight/main.go) We also provide a [complete API reference](https://pkg.go.dev/github.com/getsentry/sentry-go). diff --git a/_examples/spotlight/main.go b/_examples/spotlight/main.go new file mode 100644 index 000000000..9663e6244 --- /dev/null +++ b/_examples/spotlight/main.go @@ -0,0 +1,79 @@ +// This is an example program that demonstrates Sentry Go SDK integration +// with Spotlight for local development debugging. +// +// Try it by running: +// +// go run main.go +// +// To actually report events to Sentry, set the DSN either by editing the +// appropriate line below or setting the environment variable SENTRY_DSN to +// match the DSN of your Sentry project. +// +// Before running this example, make sure Spotlight is running: +// +// npm install -g @spotlightjs/spotlight +// spotlight +// +// Then open http://localhost:8969 in your browser to see the Spotlight UI. +package main + +import ( + "context" + "errors" + "log" + "time" + + "github.com/getsentry/sentry-go" +) + +func main() { + err := sentry.Init(sentry.ClientOptions{ + // Either set your DSN here or set the SENTRY_DSN environment variable. + Dsn: "", + // Enable printing of SDK debug messages. + // Useful when getting started or trying to figure something out. + Debug: true, + // Enable Spotlight for local debugging. + Spotlight: true, + // Enable tracing to see performance data in Spotlight. + EnableTracing: true, + TracesSampleRate: 1.0, + }) + if err != nil { + log.Fatalf("sentry.Init: %s", err) + } + // Flush buffered events before the program terminates. + // Set the timeout to the maximum duration the program can afford to wait. + defer sentry.Flush(2 * time.Second) + + log.Println("Sending sample events to Spotlight...") + + // Capture a simple message + sentry.CaptureMessage("Hello from Spotlight!") + + // Capture an exception + sentry.CaptureException(errors.New("example error for Spotlight debugging")) + + // Capture an event with additional context + sentry.WithScope(func(scope *sentry.Scope) { + scope.SetTag("environment", "development") + scope.SetLevel(sentry.LevelWarning) + scope.SetContext("example", map[string]interface{}{ + "feature": "spotlight_integration", + "version": "1.0.0", + }) + sentry.CaptureMessage("Event with additional context") + }) + + // Performance monitoring example + span := sentry.StartSpan(context.Background(), "example.operation") + defer span.Finish() + + span.SetData("example", "data") + childSpan := span.StartChild("child.operation") + // Simulate some work + time.Sleep(100 * time.Millisecond) + childSpan.Finish() + + log.Println("Events sent! Check your Spotlight UI at http://localhost:8969") +} diff --git a/client.go b/client.go index 3a05846e9..8835bea0b 100644 --- a/client.go +++ b/client.go @@ -243,6 +243,13 @@ type ClientOptions struct { // // By default, this is empty and all status codes are traced. TraceIgnoreStatusCodes [][]int + // Enable Spotlight for local development debugging. + // When enabled, events are sent to the local Spotlight sidecar. + // Default Spotlight URL is http://localhost:8969/ + Spotlight bool + // SpotlightURL is the URL to send events to when Spotlight is enabled. + // Defaults to http://localhost:8969/stream + SpotlightURL string } // Client is the underlying processor that is used by the main API and Hub @@ -370,6 +377,12 @@ func NewClient(options ClientOptions) (*Client, error) { } func (client *Client) setupTransport() { + if !client.options.Spotlight { + if spotlightEnv := os.Getenv("SENTRY_SPOTLIGHT"); spotlightEnv == "true" || spotlightEnv == "1" { + client.options.Spotlight = true + } + } + opts := client.options transport := opts.Transport @@ -381,6 +394,10 @@ func (client *Client) setupTransport() { } } + if opts.Spotlight { + transport = NewSpotlightTransport(transport) + } + transport.Configure(opts) client.Transport = transport } @@ -393,6 +410,7 @@ func (client *Client) setupIntegrations() { new(ignoreErrorsIntegration), new(ignoreTransactionsIntegration), new(globalTagsIntegration), + new(spotlightIntegration), } if client.options.Integrations != nil { diff --git a/integrations.go b/integrations.go index 70acf9147..5e95dd124 100644 --- a/integrations.go +++ b/integrations.go @@ -389,3 +389,30 @@ func loadEnvTags() map[string]string { } return tags } + +// ================================ +// Spotlight Integration +// ================================ + +type spotlightIntegration struct{} + +func (si *spotlightIntegration) Name() string { + return "Spotlight" +} + +func (si *spotlightIntegration) SetupOnce(client *Client) { + // The spotlight integration doesn't add event processors. + // It works by wrapping the transport in setupTransport(). + // This integration is mainly for completeness and debugging visibility. + if client.options.Spotlight { + DebugLogger.Printf("Spotlight integration enabled. Events will be sent to %s", + client.getSpotlightURL()) + } +} + +func (client *Client) getSpotlightURL() string { + if client.options.SpotlightURL != "" { + return client.options.SpotlightURL + } + return "http://localhost:8969/stream" +} diff --git a/spotlight_test.go b/spotlight_test.go new file mode 100644 index 000000000..5e9c80e14 --- /dev/null +++ b/spotlight_test.go @@ -0,0 +1,170 @@ +package sentry + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestSpotlightTransport(t *testing.T) { + // Mock Spotlight server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST, got %s", r.Method) + } + if r.URL.Path != "/stream" { + t.Errorf("Expected /stream, got %s", r.URL.Path) + } + if ct := r.Header.Get("Content-Type"); ct != "application/x-sentry-envelope" { + t.Errorf("Expected application/x-sentry-envelope, got %s", ct) + } + if ua := r.Header.Get("User-Agent"); ua != "sentry-go/"+SDKVersion { + t.Errorf("Expected sentry-go/%s, got %s", SDKVersion, ua) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + mock := &mockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{SpotlightURL: server.URL + "/stream"}) + + event := NewEvent() + event.Message = "Test message" + st.SendEvent(event) + + time.Sleep(100 * time.Millisecond) + + if len(mock.events) != 1 { + t.Errorf("Expected 1 event, got %d", len(mock.events)) + } + if mock.events[0].Message != "Test message" { + t.Errorf("Expected 'Test message', got %s", mock.events[0].Message) + } +} + +func TestSpotlightTransportWithNoopUnderlying(_ *testing.T) { + // Mock Spotlight server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + st := NewSpotlightTransport(noopTransport{}) + st.Configure(ClientOptions{SpotlightURL: server.URL + "/stream"}) + + event := NewEvent() + event.Message = "Test message" + st.SendEvent(event) +} + +func TestSpotlightClientOptions(t *testing.T) { + tests := []struct { + name string + options ClientOptions + envVar string + wantErr bool + hasSpotlight bool + }{ + { + name: "Spotlight enabled with DSN", + options: ClientOptions{ + Dsn: "https://user@sentry.io/123", + Spotlight: true, + }, + hasSpotlight: true, + }, + { + name: "Spotlight enabled without DSN", + options: ClientOptions{ + Spotlight: true, + }, + hasSpotlight: true, + }, + { + name: "Spotlight disabled", + options: ClientOptions{ + Dsn: "https://user@sentry.io/123", + }, + hasSpotlight: false, + }, + { + name: "Spotlight with custom URL", + options: ClientOptions{ + Spotlight: true, + SpotlightURL: "http://custom:9000/events", + }, + hasSpotlight: true, + }, + { + name: "Spotlight enabled via env var", + options: ClientOptions{ + Dsn: "https://user@sentry.io/123", + }, + envVar: "true", + hasSpotlight: true, + }, + { + name: "Spotlight enabled via env var (numeric)", + options: ClientOptions{ + Dsn: "https://user@sentry.io/123", + }, + envVar: "1", + hasSpotlight: true, + }, + { + name: "Spotlight disabled via env var", + options: ClientOptions{ + Dsn: "https://user@sentry.io/123", + }, + envVar: "false", + hasSpotlight: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envVar != "" { + t.Setenv("SENTRY_SPOTLIGHT", tt.envVar) + } + + client, err := NewClient(tt.options) + if (err != nil) != tt.wantErr { + t.Errorf("NewClient() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if err != nil { + return + } + + _, isSpotlight := client.Transport.(*SpotlightTransport) + if isSpotlight != tt.hasSpotlight { + t.Errorf("Expected SpotlightTransport = %v, got %v", tt.hasSpotlight, isSpotlight) + } + }) + } +} + +// mockTransport is a simple transport for testing. +type mockTransport struct { + events []*Event +} + +func (m *mockTransport) Configure(ClientOptions) {} + +func (m *mockTransport) SendEvent(event *Event) { + m.events = append(m.events, event) +} + +func (m *mockTransport) Flush(time.Duration) bool { + return true +} + +func (m *mockTransport) FlushWithContext(_ context.Context) bool { + return true +} + +func (m *mockTransport) Close() {} diff --git a/transport.go b/transport.go index aae5072d6..59dd2f9c6 100644 --- a/transport.go +++ b/transport.go @@ -484,7 +484,11 @@ started: } fail: - DebugLogger.Println("Buffer flushing was canceled or timed out.") + if itemCount := len(b.items); itemCount > 0 { + DebugLogger.Printf("Buffer flushing was canceled or timed out. %d items were not flushed.", itemCount) + } else { + DebugLogger.Println("Buffer flushing was canceled or timed out. No items were pending.") + } return false } @@ -742,3 +746,99 @@ func (noopTransport) FlushWithContext(context.Context) bool { } func (noopTransport) Close() {} + +// SpotlightTransport decorates Transport to also send events to Spotlight. +type SpotlightTransport struct { + underlying Transport + client *http.Client + spotlightURL string +} + +func NewSpotlightTransport(underlying Transport) *SpotlightTransport { + return &SpotlightTransport{ + underlying: underlying, + client: &http.Client{ + Timeout: 5 * time.Second, + }, + spotlightURL: "http://localhost:8969/stream", + } +} + +func (st *SpotlightTransport) Configure(options ClientOptions) { + st.underlying.Configure(options) + + if options.SpotlightURL != "" { + st.spotlightURL = options.SpotlightURL + } +} + +func (st *SpotlightTransport) SendEvent(event *Event) { + // Send to the underlying transport (Sentry) unless it's noopTransport + if _, isNoop := st.underlying.(noopTransport); !isNoop { + st.underlying.SendEvent(event) + } + + // Always send to Spotlight + st.sendToSpotlight(event) +} + +func (st *SpotlightTransport) sendToSpotlight(event *Event) { + dsn, _ := NewDsn("https://placeholder@localhost/1") + + eventBody := getRequestBodyFromEvent(event) + if eventBody == nil { + DebugLogger.Println("Failed to serialize event for Spotlight") + return + } + + envelope, err := envelopeFromBody(event, dsn, time.Now(), eventBody) + if err != nil { + DebugLogger.Printf("Failed to create Spotlight envelope: %v", err) + return + } + + req, err := http.NewRequest("POST", st.spotlightURL, envelope) + if err != nil { + DebugLogger.Printf("Failed to create Spotlight request: %v", err) + return + } + + req.Header.Set("Content-Type", "application/x-sentry-envelope") + req.Header.Set("User-Agent", "sentry-go/"+SDKVersion) + + DebugLogger.Printf("Sending event to Spotlight at %s", st.spotlightURL) + + resp, err := st.client.Do(req) + if err != nil { + DebugLogger.Printf("Failed to send event to Spotlight: %v", err) + return + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + DebugLogger.Printf("Failed to close Spotlight response body: %v", closeErr) + } + }() + + DebugLogger.Printf("Spotlight response status: %d", resp.StatusCode) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + DebugLogger.Printf("Spotlight returned non-2xx status: %d", resp.StatusCode) + if body, err := io.ReadAll(resp.Body); err == nil && len(body) > 0 { + DebugLogger.Printf("Spotlight error response: %s", string(body)) + } + } else { + DebugLogger.Printf("Successfully sent event to Spotlight") + } +} + +func (st *SpotlightTransport) Flush(timeout time.Duration) bool { + return st.underlying.Flush(timeout) +} + +func (st *SpotlightTransport) FlushWithContext(ctx context.Context) bool { + return st.underlying.FlushWithContext(ctx) +} + +func (st *SpotlightTransport) Close() { + st.underlying.Close() +}