diff --git a/README.md b/README.md index 1941fa064..9e57e3aef 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,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..dcf88dad8 --- /dev/null +++ b/_examples/spotlight/main.go @@ -0,0 +1,86 @@ +// This is an example program that demonstrates Sentry Go SDK integration +// with Spotlight for local development debugging. +// +// Spotlight allows you to see all events captured by your application in a +// local development web UI, without sending them to a Sentry server. This is +// useful for debugging during development. +// +// Try it by running: +// +// go run main.go +// +// Configuration: +// - Spotlight is enabled by default in this example (Spotlight: true) +// - Events are NOT sent to Sentry (DSN is empty) +// - To also send events to Sentry, set DSN via environment variable: +// SENTRY_DSN=https://key@sentry.io/project go run main.go +// - Or edit the DSN field below +// +// 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 11fa168cf..c32531889 100644 --- a/client.go +++ b/client.go @@ -279,10 +279,43 @@ type ClientOptions struct { // IMPORTANT: to not ignore any status codes, the option should be an empty slice and not nil. The nil option is // used for defaulting to 404 ignores. 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/stream + Spotlight bool + // SpotlightURL is the URL to send events to when Spotlight is enabled. + // Defaults to http://localhost:8969/stream + SpotlightURL string // DisableTelemetryBuffer disables the telemetry buffer layer for prioritizing events and uses the old transport layer. DisableTelemetryBuffer bool } +// spotlightConfigValue represents the parsed result of SENTRY_SPOTLIGHT env var or config. +type spotlightConfigValue struct { + enabled bool + url string +} + +// parseSpotlightEnvVar parses the SENTRY_SPOTLIGHT environment variable. +// Truthy values ("true", "t", "y", "yes", "on", "1") enable Spotlight with the default URL. +// Falsy values ("false", "f", "n", "no", "off", "0") disable it. +// Any other non-empty string is treated as a custom Spotlight URL. +func parseSpotlightEnvVar(value string) spotlightConfigValue { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return spotlightConfigValue{enabled: false} + } + + switch strings.ToLower(trimmed) { + case "true", "t", "y", "yes", "on", "1": + return spotlightConfigValue{enabled: true} + case "false", "f", "n", "no", "off", "0": + return spotlightConfigValue{enabled: false} + } + + return spotlightConfigValue{enabled: true, url: trimmed} +} + // Client is the underlying processor that is used by the main API and Hub // instances. It must be created with NewClient. type Client struct { @@ -365,6 +398,47 @@ func NewClient(options ClientOptions) (*Client, error) { options.TraceIgnoreStatusCodes = [][]int{{404}} } + // Handle Spotlight configuration with environment variable precedence + spotlightEnvVar := os.Getenv("SENTRY_SPOTLIGHT") + if spotlightEnvVar != "" { + envConfig := parseSpotlightEnvVar(spotlightEnvVar) + + switch { + case options.SpotlightURL != "": + // Config URL explicitly set: use it and warn + debuglog.Printf("Both SpotlightURL config and SENTRY_SPOTLIGHT env var are set. Using config URL: %s", options.SpotlightURL) + options.Spotlight = true + case options.Spotlight && envConfig.url != "": + // Config enables Spotlight but no URL, env var has URL: use env var URL + options.SpotlightURL = envConfig.url + debuglog.Printf("Spotlight enabled via config but using URL from SENTRY_SPOTLIGHT: %s", envConfig.url) + case !options.Spotlight: + // Config doesn't set Spotlight: use env var setting + options.Spotlight = envConfig.enabled + if envConfig.url != "" { + options.SpotlightURL = envConfig.url + } + if envConfig.enabled { + debuglog.Println("Spotlight enabled via SENTRY_SPOTLIGHT env var") + } else { + debuglog.Println("Spotlight disabled via SENTRY_SPOTLIGHT env var") + } + } + } + + // Spotlight is a local development tool: always deliver 100% of events + // with full PII so the developer sees everything their app is generating. + if options.Spotlight { + if options.SampleRate != 1.0 { + debuglog.Printf("Overriding SampleRate from %.2f to 1.0 for Spotlight", options.SampleRate) + options.SampleRate = 1.0 + } + if !options.SendDefaultPII { + debuglog.Println("Enabling SendDefaultPII for Spotlight") + options.SendDefaultPII = true + } + } + // SENTRYGODEBUG is a comma-separated list of key=value pairs (similar // to GODEBUG). It is not a supported feature: recognized debug options // may change any time. @@ -463,6 +537,10 @@ func (client *Client) setupTransport() { } } + if opts.Spotlight { + transport = NewSpotlightTransport(transport) + } + transport.Configure(opts) client.Transport = transport } @@ -528,6 +606,7 @@ func (client *Client) setupIntegrations() { new(ignoreErrorsIntegration), new(ignoreTransactionsIntegration), new(globalTagsIntegration), + new(spotlightIntegration), } if client.options.Integrations != nil { @@ -907,6 +986,12 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod debuglog.Println("Event dropped: telemetry buffer full or unavailable") } } else { + // For Spotlight: build and send envelope for enhanced data collection + if client.options.Spotlight { + envelope := client.buildEnvelopeFromEvent(event) + client.Transport.SendEnvelope(envelope) + } + // Default path: send event directly for backwards compatibility client.Transport.SendEvent(event) } @@ -1027,6 +1112,45 @@ func (client *Client) integrationAlreadyInstalled(name string) bool { return false } +// buildEnvelopeFromEvent builds an envelope from an event. +// This is used to send events via the envelope-based transport API. +func (client *Client) buildEnvelopeFromEvent(event *Event) *protocol.Envelope { + header := &protocol.EnvelopeHeader{ + EventID: string(event.EventID), + SentAt: time.Now(), + Sdk: &protocol.SdkInfo{ + Name: event.Sdk.Name, + Version: event.Sdk.Version, + }, + } + + if client.dsn != nil { + header.Dsn = client.dsn.String() + } + + if header.EventID == "" { + header.EventID = protocol.GenerateEventID() + } + + envelope := protocol.NewEnvelope(header) + + // Convert event to envelope item + item, err := event.ToEnvelopeItem() + if err != nil { + debuglog.Printf("Failed to convert event to envelope item: %v", err) + return envelope + } + envelope.AddItem(item) + + // Add attachments + for _, attachment := range event.Attachments { + attachmentItem := protocol.NewAttachmentItem(attachment.Filename, attachment.ContentType, attachment.Payload) + envelope.AddItem(attachmentItem) + } + + return envelope +} + // sample returns true with the given probability, which must be in the range // [0.0, 1.0]. func sample(probability float64) bool { diff --git a/hub_test.go b/hub_test.go index 6f92a77e0..b3e1c819a 100644 --- a/hub_test.go +++ b/hub_test.go @@ -499,6 +499,10 @@ func TestHub_Flush(t *testing.T) { if gotEvents[0].Message != wantEvent.Message { t.Fatalf("expected message to be %v, got %v", wantEvent.Message, gotEvents[0].Message) } + + if transport.FlushCount() != 1 { + t.Fatalf("expected transport.Flush called 1 time, got %d", transport.FlushCount()) + } } func TestHub_Flush_NoClient(t *testing.T) { @@ -540,4 +544,8 @@ func TestHub_FlushWithContext(t *testing.T) { if gotEvents[0].Message != wantEvent.Message { t.Fatalf("expected message to be %v, got %v", wantEvent.Message, gotEvents[0].Message) } + + if transport.FlushCount() != 1 { + t.Fatalf("expected transport.FlushWithContext called 1 time, got %d", transport.FlushCount()) + } } diff --git a/integrations.go b/integrations.go index 60cc73d57..69ef7c108 100644 --- a/integrations.go +++ b/integrations.go @@ -391,3 +391,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/mocks.go b/mocks.go index 9492b1c24..73962dee7 100644 --- a/mocks.go +++ b/mocks.go @@ -2,8 +2,11 @@ package sentry import ( "context" + "encoding/json" "sync" "time" + + "github.com/getsentry/sentry-go/internal/protocol" ) // MockScope implements [Scope] for use in tests. @@ -25,9 +28,11 @@ func (scope *MockScope) ApplyToEvent(event *Event, _ *EventHint, _ *Client) *Eve // MockTransport implements [Transport] for use in tests. type MockTransport struct { - mu sync.Mutex - events []*Event - lastEvent *Event + mu sync.Mutex + events []*Event + lastEvent *Event + flushCount int + closeCount int } func (t *MockTransport) Configure(_ ClientOptions) {} @@ -37,16 +42,60 @@ func (t *MockTransport) SendEvent(event *Event) { t.events = append(t.events, event) t.lastEvent = event } +func (t *MockTransport) SendEnvelope(envelope *protocol.Envelope) { + // For now, extract event from envelope and send via SendEvent + if envelope == nil || len(envelope.Items) == 0 { + return + } + item := envelope.Items[0] + if item.Payload != nil { + var event Event + if err := json.Unmarshal(item.Payload, &event); err == nil { + t.SendEvent(&event) + } + } +} func (t *MockTransport) Flush(_ time.Duration) bool { + t.mu.Lock() + defer t.mu.Unlock() + t.flushCount++ + return true +} +func (t *MockTransport) FlushWithContext(_ context.Context) bool { + t.mu.Lock() + defer t.mu.Unlock() + t.flushCount++ return true } -func (t *MockTransport) FlushWithContext(_ context.Context) bool { return true } func (t *MockTransport) Events() []*Event { t.mu.Lock() defer t.mu.Unlock() return t.events } -func (t *MockTransport) Close() {} +func (t *MockTransport) FlushCount() int { + t.mu.Lock() + defer t.mu.Unlock() + return t.flushCount +} +func (t *MockTransport) Close() { + t.mu.Lock() + defer t.mu.Unlock() + t.closeCount++ +} +func (t *MockTransport) CloseCount() int { + t.mu.Lock() + defer t.mu.Unlock() + return t.closeCount +} + +// ResetCounts resets the flush and close counters. +// Useful for multi-assertion tests that check operation counts at different stages. +func (t *MockTransport) ResetCounts() { + t.mu.Lock() + defer t.mu.Unlock() + t.flushCount = 0 + t.closeCount = 0 +} // MockLogEntry implements [sentry.LogEntry] for use in tests. type MockLogEntry struct { diff --git a/spotlight_test.go b/spotlight_test.go new file mode 100644 index 000000000..ef51b3a3c --- /dev/null +++ b/spotlight_test.go @@ -0,0 +1,941 @@ +package sentry + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/getsentry/sentry-go/internal/protocol" +) + +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.Sdk.Name = "sentry-go" + event.Sdk.Version = SDKVersion + 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) + } + + if !st.Flush(time.Second) { + t.Errorf("Expected Flush to succeed") + } + + if mock.FlushCount() != 1 { + t.Errorf("Expected underlying transport Flush called 1 time, got %d", mock.FlushCount()) + } +} + +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) + } + }) + } +} + +func TestSpotlightURLPrecedence(t *testing.T) { + defaultURL := "http://localhost:8969/stream" + + tests := []struct { + name string + options ClientOptions + envVar string + wantURL string + description string + }{ + { + name: "Default URL when spotlight=true, no URL, no env var", + options: ClientOptions{ + Spotlight: true, + }, + wantURL: defaultURL, + description: "Should use default URL", + }, + { + name: "Config URL takes precedence over env var URL", + options: ClientOptions{ + Spotlight: true, + SpotlightURL: "http://config.url/stream", + }, + envVar: "http://env.url/stream", + wantURL: "http://config.url/stream", + description: "Config URL should take precedence", + }, + { + name: "Env var URL used when spotlight=true, no URL, SENTRY_SPOTLIGHT=URL", + options: ClientOptions{ + Spotlight: true, + }, + envVar: "http://env.url/stream", + wantURL: "http://env.url/stream", + description: "Env var URL should be used", + }, + { + name: "Env var URL used when no config, SENTRY_SPOTLIGHT=URL", + options: ClientOptions{ + Dsn: "https://user@sentry.io/123", + }, + envVar: "http://env.url/stream", + wantURL: "http://env.url/stream", + description: "Env var URL should be used", + }, + { + name: "Default URL when SENTRY_SPOTLIGHT=true, no config", + options: ClientOptions{ + Dsn: "https://user@sentry.io/123", + }, + envVar: "true", + wantURL: defaultURL, + description: "Default URL should be used", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envVar != "" { + t.Setenv("SENTRY_SPOTLIGHT", tt.envVar) + } else { + t.Setenv("SENTRY_SPOTLIGHT", "") + } + + client, err := NewClient(tt.options) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + st, ok := client.Transport.(*SpotlightTransport) + if !ok { + t.Fatalf("Expected SpotlightTransport, got %T", client.Transport) + } + + if st.spotlightURL != tt.wantURL { + t.Errorf("%s: Expected URL %s, got %s", tt.description, tt.wantURL, st.spotlightURL) + } + }) + } +} + +func TestParseSpotlightEnvVar(t *testing.T) { + tests := []struct { + name string + value string + wantEnabled bool + wantURL string + }{ + // Truthy values + { + name: "true", + value: "true", + wantEnabled: true, + wantURL: "", + }, + { + name: "t", + value: "t", + wantEnabled: true, + wantURL: "", + }, + { + name: "y", + value: "y", + wantEnabled: true, + wantURL: "", + }, + { + name: "yes", + value: "yes", + wantEnabled: true, + wantURL: "", + }, + { + name: "on", + value: "on", + wantEnabled: true, + wantURL: "", + }, + { + name: "1", + value: "1", + wantEnabled: true, + wantURL: "", + }, + // Falsy values + { + name: "false", + value: "false", + wantEnabled: false, + wantURL: "", + }, + { + name: "f", + value: "f", + wantEnabled: false, + wantURL: "", + }, + { + name: "n", + value: "n", + wantEnabled: false, + wantURL: "", + }, + { + name: "no", + value: "no", + wantEnabled: false, + wantURL: "", + }, + { + name: "off", + value: "off", + wantEnabled: false, + wantURL: "", + }, + { + name: "0", + value: "0", + wantEnabled: false, + wantURL: "", + }, + // URL values + { + name: "custom URL", + value: "http://custom:9000/stream", + wantEnabled: true, + wantURL: "http://custom:9000/stream", + }, + { + name: "localhost URL", + value: "http://localhost:8969/stream", + wantEnabled: true, + wantURL: "http://localhost:8969/stream", + }, + // Edge cases + { + name: "empty string", + value: "", + wantEnabled: false, + wantURL: "", + }, + { + name: "whitespace only", + value: " ", + wantEnabled: false, + wantURL: "", + }, + { + name: "case insensitive true", + value: "TRUE", + wantEnabled: true, + wantURL: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseSpotlightEnvVar(tt.value) + if result.enabled != tt.wantEnabled { + t.Errorf("Expected enabled=%v, got %v", tt.wantEnabled, result.enabled) + } + if result.url != tt.wantURL { + t.Errorf("Expected url=%q, got %q", tt.wantURL, result.url) + } + }) + } +} + +func TestCloneAndModifyEnvelopeForSpotlight(t *testing.T) { + // Create a test envelope with an event + event := &Event{ + EventID: EventID("test123"), + Message: "Test event", + Level: LevelInfo, + } + + envelope := protocol.NewEnvelope(&protocol.EnvelopeHeader{ + EventID: "test123", + }) + + // Serialize event to JSON + eventJSON, err := json.Marshal(event) + if err != nil { + t.Fatalf("Failed to marshal event: %v", err) + } + + // Add event item to envelope + envelope.AddItem(&protocol.EnvelopeItem{ + Header: &protocol.EnvelopeItemHeader{ + Type: protocol.EnvelopeItemTypeEvent, + }, + Payload: eventJSON, + }) + + // Add an attachment item (should be copied as-is) + length := 5 + envelope.AddItem(&protocol.EnvelopeItem{ + Header: &protocol.EnvelopeItemHeader{ + Type: protocol.EnvelopeItemTypeAttachment, + Length: &length, + Filename: "test.txt", + }, + Payload: []byte("hello"), + }) + + cloned := cloneEnvelopeForSpotlight(envelope) + + // Verify cloned envelope has same number of items + if len(cloned.Items) != len(envelope.Items) { + t.Errorf("Expected %d items, got %d", len(envelope.Items), len(cloned.Items)) + } + + // Verify event item was processed + if cloned.Items[0].Header.Type != protocol.EnvelopeItemTypeEvent { + t.Errorf("Expected event item type, got %s", cloned.Items[0].Header.Type) + } + + // Verify attachment item was copied + if cloned.Items[1].Header.Type != protocol.EnvelopeItemTypeAttachment { + t.Errorf("Expected attachment item type, got %s", cloned.Items[1].Header.Type) + } + if cloned.Items[1].Header.Filename != "test.txt" { + t.Errorf("Expected filename test.txt, got %s", cloned.Items[1].Header.Filename) + } + + // Verify original envelope is unchanged + if envelope.Items[0].Header.Type != protocol.EnvelopeItemTypeEvent { + t.Errorf("Original envelope was modified") + } +} + +func TestSpotlightSampleRateOverride(t *testing.T) { + tests := []struct { + name string + inputSampleRate float64 + expectedSampleRate float64 + }{ + { + name: "Sample rate 0.5 overridden to 1.0", + inputSampleRate: 0.5, + expectedSampleRate: 1.0, + }, + { + name: "Sample rate 0.0 overridden to 1.0", + inputSampleRate: 0.0, + expectedSampleRate: 1.0, + }, + { + name: "Sample rate 1.0 unchanged", + inputSampleRate: 1.0, + expectedSampleRate: 1.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(ClientOptions{ + Spotlight: true, + SampleRate: tt.inputSampleRate, + }) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + if client.options.SampleRate != tt.expectedSampleRate { + t.Errorf("Expected SampleRate = %f, got %f", tt.expectedSampleRate, client.options.SampleRate) + } + }) + } +} + +func TestSpotlightPIIOverride(t *testing.T) { + tests := []struct { + name string + inputSendPII bool + expectedSendPII bool + }{ + { + name: "SendDefaultPII false overridden to true", + inputSendPII: false, + expectedSendPII: true, + }, + { + name: "SendDefaultPII true unchanged", + inputSendPII: true, + expectedSendPII: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(ClientOptions{ + Spotlight: true, + SendDefaultPII: tt.inputSendPII, + }) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + if client.options.SendDefaultPII != tt.expectedSendPII { + t.Errorf("Expected SendDefaultPII = %v, got %v", tt.expectedSendPII, client.options.SendDefaultPII) + } + }) + } +} + +func TestSpotlightDisabledPreservesSettings(t *testing.T) { + client, err := NewClient(ClientOptions{ + Spotlight: false, + SampleRate: 0.5, + SendDefaultPII: false, + }) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + if client.options.SampleRate != 0.5 { + t.Errorf("Expected SampleRate = 0.5, got %f", client.options.SampleRate) + } + + if client.options.SendDefaultPII { + t.Errorf("Expected SendDefaultPII = false, got %v", client.options.SendDefaultPII) + } +} + +func TestSpotlightProxyConfiguration(t *testing.T) { + // Test with HTTPProxy option + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{ + Spotlight: true, + HTTPProxy: "http://proxy.example.com:8080", + }) + + if st.client == nil { + t.Errorf("Expected HTTP client to be configured") + } + + transport, ok := st.client.Transport.(*http.Transport) + if !ok { + t.Fatalf("Expected *http.Transport, got %T", st.client.Transport) + } + + if transport.Proxy == nil { + t.Errorf("Expected Proxy to be configured") + } +} + +func TestSpotlightCustomHTTPClient(t *testing.T) { + // Create a custom HTTP client + customClient := &http.Client{ + Timeout: 10 * time.Second, + } + + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{ + Spotlight: true, + HTTPClient: customClient, + }) + + if st.client == nil { + t.Errorf("Expected HTTP client to be configured") + } + + // Spotlight enforces its own 5s timeout even when the caller supplies a longer one. + if st.client.Timeout != 5*time.Second { + t.Errorf("Expected timeout 5s for Spotlight, got %v", st.client.Timeout) + } +} + +func TestSpotlightAsyncSend(t *testing.T) { + var requestCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requestCount.Add(1) + time.Sleep(100 * time.Millisecond) // Simulate slow server + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{SpotlightURL: server.URL + "/stream"}) + + start := time.Now() + for i := 0; i < 5; i++ { + event := NewEvent() + event.Message = "Test message " + string(rune(i)) + st.SendEvent(event) + } + elapsed := time.Since(start) + + // Should return immediately, not wait for all sends to complete + if elapsed > 500*time.Millisecond { + t.Errorf("SendEvent took too long (%v), should be non-blocking", elapsed) + } + + time.Sleep(1 * time.Second) + if len(mock.Events()) != 5 { + t.Errorf("Expected 5 events in mock, got %d", len(mock.Events())) + } +} + +func TestSpotlightContextCancellation(t *testing.T) { + slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(5 * time.Second) // Very slow server + w.WriteHeader(http.StatusOK) + })) + defer slowServer.Close() + + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{SpotlightURL: slowServer.URL + "/stream"}) + + event := NewEvent() + event.Message = "Test message" + st.SendEvent(event) + + // Close immediately while the slow server is still handling the request. + // This should cancel the in-flight request rather than blocking for 5 seconds. + st.Close() + + if st.ctx.Err() == nil { + t.Errorf("Expected context to be cancelled after Close()") + } +} + +func TestSpotlightShutdownTimeout(t *testing.T) { + slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(5 * time.Second) // Much longer than shutdown timeout + w.WriteHeader(http.StatusOK) + })) + defer slowServer.Close() + + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{SpotlightURL: slowServer.URL + "/stream"}) + + // Send multiple events + for i := 0; i < 3; i++ { + event := NewEvent() + event.Message = "Test message" + st.SendEvent(event) + } + + // Close should timeout gracefully + start := time.Now() + st.Close() + elapsed := time.Since(start) + + // Should timeout after ~2 seconds, not hang + if elapsed > 3*time.Second { + t.Errorf("Close took too long (%v), should respect 2s timeout", elapsed) + } +} + +func TestSpotlightServerError(t *testing.T) { + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer errorServer.Close() + + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{SpotlightURL: errorServer.URL + "/stream"}) + + event := NewEvent() + event.Message = "Test message" + st.SendEvent(event) + + time.Sleep(500 * time.Millisecond) // Wait for async send + + // Should have sent to mock transport even if Spotlight fails + if len(mock.Events()) != 1 { + t.Errorf("Expected 1 event in mock, got %d", len(mock.Events())) + } + + st.Close() +} + +func TestSpotlightNetworkError(t *testing.T) { + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{ + SpotlightURL: "http://localhost:54321", // Unreachable port + }) + + event := NewEvent() + event.Message = "Test message" + st.SendEvent(event) + + time.Sleep(500 * time.Millisecond) // Wait for async send attempt + + // Should have sent to mock transport even if Spotlight is unreachable + if len(mock.Events()) != 1 { + t.Errorf("Expected 1 event in mock, got %d", len(mock.Events())) + } + + st.Close() +} + +func TestSpotlightSlowServer(t *testing.T) { + slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(500 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer slowServer.Close() + + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{SpotlightURL: slowServer.URL + "/stream"}) + + start := time.Now() + event := NewEvent() + event.Message = "Test message" + st.SendEvent(event) + elapsed := time.Since(start) + + // SendEvent should return immediately, not wait for server response + if elapsed > 100*time.Millisecond { + t.Errorf("SendEvent should not block on slow server, took %v", elapsed) + } + + st.Close() +} + +func TestSpotlightMultipleEvents(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{SpotlightURL: server.URL + "/stream"}) + + // Send multiple events concurrently + for i := 0; i < 10; i++ { + event := NewEvent() + event.Message = "Test message " + string(rune(i)) + st.SendEvent(event) + } + + st.Close() + + // All events should be sent to mock transport + if len(mock.Events()) != 10 { + t.Errorf("Expected 10 events in mock, got %d", len(mock.Events())) + } +} + +func TestSpotlightSendEnvelope(t *testing.T) { + var requestCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requestCount.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{SpotlightURL: server.URL + "/stream"}) + + // Build and send an envelope + envelope := protocol.NewEnvelope(&protocol.EnvelopeHeader{ + EventID: "test-envelope-123", + }) + event := &Event{ + EventID: "test-envelope-123", + Message: "Envelope test", + Level: LevelError, + } + eventJSON, err := json.Marshal(event) + if err != nil { + t.Fatalf("Failed to marshal event: %v", err) + } + envelope.AddItem(&protocol.EnvelopeItem{ + Header: &protocol.EnvelopeItemHeader{Type: protocol.EnvelopeItemTypeEvent}, + Payload: eventJSON, + }) + + st.SendEnvelope(envelope) + time.Sleep(200 * time.Millisecond) // Wait for async send + + if len(mock.Events()) != 1 { + t.Errorf("Expected 1 event in mock (from envelope), got %d", len(mock.Events())) + } + if requestCount.Load() != 1 { + t.Errorf("Expected 1 request to Spotlight, got %d", requestCount.Load()) + } + + st.Close() +} + +func TestSpotlightSendEnvelopeEmpty(_ *testing.T) { + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{SpotlightURL: "http://localhost:54321/stream"}) + + emptyEnvelope := protocol.NewEnvelope(&protocol.EnvelopeHeader{}) + st.SendEnvelope(emptyEnvelope) + time.Sleep(100 * time.Millisecond) + + st.Close() +} + +func TestSpotlightFlushWithContext(t *testing.T) { + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{}) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + result := st.FlushWithContext(ctx) + if !result { + t.Errorf("Expected FlushWithContext to succeed") + } +} + +func TestSpotlightSendEnvelopeWithSDK(t *testing.T) { + var requestCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requestCount.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{SpotlightURL: server.URL + "/stream"}) + + envelope := protocol.NewEnvelope(&protocol.EnvelopeHeader{ + EventID: "test-sdk-123", + Sdk: &protocol.SdkInfo{ + Name: "sentry-go", + Version: SDKVersion, + Integrations: []string{"spotlight"}, + }, + }) + event := &Event{ + EventID: "test-sdk-123", + Message: "SDK envelope test", + } + eventJSON, _ := json.Marshal(event) + envelope.AddItem(&protocol.EnvelopeItem{ + Header: &protocol.EnvelopeItemHeader{Type: protocol.EnvelopeItemTypeEvent}, + Payload: eventJSON, + }) + + st.SendEnvelope(envelope) + time.Sleep(200 * time.Millisecond) + st.Close() + + if requestCount.Load() != 1 { + t.Errorf("Expected 1 request to Spotlight, got %d", requestCount.Load()) + } +} + +func TestSpotlightBuildHTTPClientWithTransport(t *testing.T) { + customTransport := &http.Transport{} + + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{ + HTTPTransport: customTransport, + }) + + if st.client == nil { + t.Fatalf("Expected HTTP client to be configured") + } + if st.client.Transport != customTransport { + t.Errorf("Expected custom transport to be used") + } +} + +func TestSpotlightEnvelopeCancelledContext(_ *testing.T) { + // Test that sendToSpotlightServer skips when context is already cancelled + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{SpotlightURL: "http://localhost:54321/stream"}) + + // Cancel the context before sending + st.cancel() + + // Build an envelope with an event + envelope := protocol.NewEnvelope(&protocol.EnvelopeHeader{EventID: "test-cancel"}) + event := &Event{EventID: "test-cancel", Message: "cancelled"} + eventJSON, _ := json.Marshal(event) + envelope.AddItem(&protocol.EnvelopeItem{ + Header: &protocol.EnvelopeItemHeader{Type: protocol.EnvelopeItemTypeEvent}, + Payload: eventJSON, + }) + + // Directly call sendEnvelopeToSpotlight — context already cancelled, should skip gracefully + st.sendEnvelopeToSpotlight(envelope) +} + +func TestSpotlightSendEventCancelledContext(_ *testing.T) { + // Test that sendToSpotlight skips when context is already cancelled + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{SpotlightURL: "http://localhost:54321/stream"}) + + // Cancel context before calling sendToSpotlight directly + st.cancel() + + event := NewEvent() + event.Message = "cancelled event" + // Call directly (bypassing the goroutine wrapper) to test the ctx.Done() check + st.sendToSpotlight(event) +} + +func TestSpotlightSendEnvelopeServerError(_ *testing.T) { + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer errorServer.Close() + + mock := &MockTransport{} + st := NewSpotlightTransport(mock) + st.Configure(ClientOptions{SpotlightURL: errorServer.URL + "/stream"}) + + envelope := protocol.NewEnvelope(&protocol.EnvelopeHeader{EventID: "test-server-error"}) + event := &Event{EventID: "test-server-error", Message: "server error test"} + eventJSON, _ := json.Marshal(event) + envelope.AddItem(&protocol.EnvelopeItem{ + Header: &protocol.EnvelopeItemHeader{Type: protocol.EnvelopeItemTypeEvent}, + Payload: eventJSON, + }) + + st.SendEnvelope(envelope) + time.Sleep(200 * time.Millisecond) + st.Close() +} diff --git a/transport.go b/transport.go index e813c0c80..be3f97378 100644 --- a/transport.go +++ b/transport.go @@ -32,6 +32,7 @@ type Transport interface { FlushWithContext(ctx context.Context) bool Configure(options ClientOptions) SendEvent(event *Event) + SendEnvelope(envelope *protocol.Envelope) Close() } @@ -507,6 +508,26 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event) t.buffer <- b } +// SendEnvelope sends an envelope to the Sentry server. +// Currently converts envelope back to event for compatibility. +// In the future, this will be the primary method. +func (t *HTTPTransport) SendEnvelope(envelope *protocol.Envelope) { + // For now, as a temporary implementation, extract event from envelope and send via SendEvent + // This maintains backwards compatibility while we transition to envelope-based transport + if envelope == nil || len(envelope.Items) == 0 { + return + } + + // Try to extract event from first item and send it + item := envelope.Items[0] + if item.Payload != nil { + var event Event + if err := json.Unmarshal(item.Payload, &event); err == nil { + t.SendEvent(&event) + } + } +} + // Flush waits until any buffered events are sent to the Sentry server, blocking // for at most the given timeout. It returns false if the timeout was reached. // In that case, some events may not have been sent. @@ -572,7 +593,7 @@ started: } fail: - debuglog.Println("Buffer flushing was canceled or timed out.") + debuglog.Println("Buffer flushing was canceled or timed out. Some events may not have been sent.") return false } @@ -777,6 +798,23 @@ func (t *HTTPSyncTransport) SendEvent(event *Event) { t.SendEventWithContext(context.Background(), event) } +func (t *HTTPSyncTransport) SendEnvelope(envelope *protocol.Envelope) { + // For now, as a temporary implementation, extract event from envelope and send via SendEvent + // This maintains backwards compatibility while we transition to envelope-based transport + if envelope == nil || len(envelope.Items) == 0 { + return + } + + // Try to extract event from first item and send it + item := envelope.Items[0] + if item.Payload != nil { + var event Event + if err := json.Unmarshal(item.Payload, &event); err == nil { + t.SendEvent(&event) + } + } +} + func (t *HTTPSyncTransport) Close() {} // SendEventWithContext assembles a new packet out of Event and sends it to the remote server. @@ -877,6 +915,10 @@ func (noopTransport) SendEvent(*Event) { debuglog.Println("Event dropped due to noopTransport usage.") } +func (noopTransport) SendEnvelope(*protocol.Envelope) { + debuglog.Println("Envelope dropped due to noopTransport usage.") +} + func (noopTransport) Flush(time.Duration) bool { return true } @@ -887,6 +929,271 @@ 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 + + // ctx is cancelled on Close to signal goroutines to stop. + // wg tracks in-flight sends so Close can wait for them to finish. + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup +} + +func NewSpotlightTransport(underlying Transport) *SpotlightTransport { + ctx, cancel := context.WithCancel(context.Background()) + return &SpotlightTransport{ + underlying: underlying, + client: &http.Client{ + Timeout: 5 * time.Second, + }, + spotlightURL: "http://localhost:8969/stream", + ctx: ctx, + cancel: cancel, + } +} + +func (st *SpotlightTransport) Configure(options ClientOptions) { + st.underlying.Configure(options) + + if options.SpotlightURL != "" { + st.spotlightURL = options.SpotlightURL + } + + st.buildHTTPClient(options) +} + +func (st *SpotlightTransport) buildHTTPClient(opts ClientOptions) { + if opts.HTTPClient != nil { + // Copy the user's client but enforce Spotlight's own timeout. + // Spotlight is dev-only; we don't want it inheriting a long or zero timeout. + st.client = &http.Client{ + Transport: opts.HTTPClient.Transport, + CheckRedirect: opts.HTTPClient.CheckRedirect, + Jar: opts.HTTPClient.Jar, + Timeout: 5 * time.Second, + } + return + } + + var transport http.RoundTripper + if opts.HTTPTransport != nil { + transport = opts.HTTPTransport + } else { + transport = &http.Transport{ + Proxy: getProxyConfig(opts), + TLSClientConfig: getTLSConfig(opts), + } + } + + st.client = &http.Client{ + Transport: transport, + Timeout: 5 * time.Second, + } +} + +func (st *SpotlightTransport) SendEvent(event *Event) { + st.underlying.SendEvent(event) + st.wg.Add(1) + go func() { + defer st.wg.Done() + st.sendToSpotlight(event) + }() +} + +func (st *SpotlightTransport) SendEnvelope(envelope *protocol.Envelope) { + st.underlying.SendEnvelope(envelope) + st.wg.Add(1) + go func() { + defer st.wg.Done() + st.sendEnvelopeToSpotlight(envelope) + }() +} + +func (st *SpotlightTransport) sendEnvelopeToSpotlight(envelope *protocol.Envelope) { + if envelope == nil || len(envelope.Items) == 0 { + return + } + + spotlightEnvelope := cloneEnvelopeForSpotlight(envelope) + + envelopeBytes, err := spotlightEnvelope.Serialize() + if err != nil { + DebugLogger.Printf("Failed to serialize envelope for Spotlight: %v", err) + return + } + + st.sendToSpotlightServer(envelopeBytes) +} + +// cloneEnvelopeForSpotlight creates a deep copy of an envelope for sending to Spotlight. +// We clone so that Spotlight's send path doesn't race with or mutate the envelope +// already handed to the underlying Sentry transport. +func cloneEnvelopeForSpotlight(envelope *protocol.Envelope) *protocol.Envelope { + newHeader := &protocol.EnvelopeHeader{ + EventID: envelope.Header.EventID, + SentAt: envelope.Header.SentAt, + Dsn: envelope.Header.Dsn, + Trace: envelope.Header.Trace, + } + if envelope.Header.Sdk != nil { + newHeader.Sdk = &protocol.SdkInfo{ + Name: envelope.Header.Sdk.Name, + Version: envelope.Header.Sdk.Version, + Integrations: append([]string{}, envelope.Header.Sdk.Integrations...), + Packages: append([]protocol.SdkPackage{}, envelope.Header.Sdk.Packages...), + } + } + + cloned := protocol.NewEnvelope(newHeader) + for _, item := range envelope.Items { + if item == nil { + continue + } + cloned.AddItem(&protocol.EnvelopeItem{ + Header: item.Header, + Payload: append([]byte(nil), item.Payload...), + }) + } + return cloned +} + +func (st *SpotlightTransport) isShuttingDown() bool { + return st.ctx.Err() != nil +} + +func (st *SpotlightTransport) sendToSpotlightServer(envelopeBytes []byte) { + if st.isShuttingDown() { + DebugLogger.Printf("Skipping Spotlight send: transport shutting down") + return + } + + timeoutCtx, cancel := context.WithTimeout(st.ctx, st.client.Timeout) + defer cancel() + + req, err := http.NewRequestWithContext(timeoutCtx, "POST", st.spotlightURL, bytes.NewReader(envelopeBytes)) + if err != nil { + DebugLogger.Printf("Failed to create Spotlight request: %v", err) + return + } + + req.Header.Set("Content-Type", "application/x-sentry-envelope") + if SDKVersion != "" { + req.Header.Set("User-Agent", "sentry-go/"+SDKVersion) + } + + resp, err := st.client.Do(req) + if err != nil { + DebugLogger.Printf("Failed to send envelope to Spotlight at %s: %v. "+ + "Make sure Spotlight is running (npm install -g @spotlightjs/spotlight && spotlight)", st.spotlightURL, err) + return + } + defer func() { + // Drain body up to a limit and close it, allowing the + // transport to reuse TCP connections. + _, _ = io.CopyN(io.Discard, resp.Body, util.MaxDrainResponseBytes) + _ = resp.Body.Close() + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + DebugLogger.Printf("Spotlight server at %s returned status %d", st.spotlightURL, resp.StatusCode) + } +} + +func (st *SpotlightTransport) sendToSpotlight(event *Event) { + if st.isShuttingDown() { + DebugLogger.Printf("Skipping Spotlight send: transport shutting down") + return + } + + // envelopeFromBody requires a DSN to set the envelope URL; the value is + // irrelevant because we POST directly to spotlightURL, not the DSN endpoint. + dsn, err := NewDsn("https://placeholder@localhost/1") + if err != nil { + DebugLogger.Printf("Failed to create Spotlight DSN: %v", err) + return + } + + 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 + } + + timeoutCtx, cancel := context.WithTimeout(st.ctx, st.client.Timeout) + defer cancel() + + req, err := http.NewRequestWithContext(timeoutCtx, "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", fmt.Sprintf("%s/%s", event.Sdk.Name, event.Sdk.Version)) + + 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() { + // Drain body up to a limit and close it, allowing the + // transport to reuse TCP connections. + _, _ = io.CopyN(io.Discard, resp.Body, util.MaxDrainResponseBytes) + 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) + } else { + DebugLogger.Printf("Successfully sent event to Spotlight") + } +} + +// Flush waits for the underlying transport to send pending events to Sentry. +// In-flight Spotlight sends are best-effort and not waited on. +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.cancel() + + done := make(chan struct{}) + go func() { + st.wg.Wait() + close(done) + }() + + select { + case <-done: + DebugLogger.Printf("All Spotlight sends completed") + case <-time.After(2 * time.Second): + DebugLogger.Printf("Spotlight sends timed out during shutdown") + } + + st.underlying.Close() +} + // ================================ // Internal Transport Adapters // ================================ @@ -961,6 +1268,12 @@ func (a *internalAsyncTransportAdapter) SendEvent(event *Event) { } } +func (a *internalAsyncTransportAdapter) SendEnvelope(envelope *protocol.Envelope) { + if err := a.transport.SendEnvelope(envelope); err != nil { + debuglog.Printf("Error sending envelope: %v", err) + } +} + func (a *internalAsyncTransportAdapter) Flush(timeout time.Duration) bool { return a.transport.Flush(timeout) }