diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index 6815aa0d95..6519e068f9 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -111,8 +111,16 @@ public void ApplyTo(SentryOptions options) options.UseAsyncFileIO = UseAsyncFileIO ?? options.UseAsyncFileIO; options.DisableSentryHttpMessageHandler = DisableSentryHttpMessageHandler ?? options.DisableSentryHttpMessageHandler; options.JsonPreserveReferences = JsonPreserveReferences ?? options.JsonPreserveReferences; - options.EnableSpotlight = EnableSpotlight ?? options.EnableSpotlight; - options.SpotlightUrl = SpotlightUrl ?? options.SpotlightUrl; + // Deliberately not the usual `?? options.X` pattern: only assign when bound, to avoid marking + // these as explicitly set (which drives env-var precedence in SettingLocator.ResolveSpotlight). + if (EnableSpotlight.HasValue) + { + options.EnableSpotlight = EnableSpotlight.Value; + } + if (SpotlightUrl is not null) + { + options.SpotlightUrl = SpotlightUrl; + } #if ANDROID Android.ApplyTo(options.Android); diff --git a/src/Sentry/DynamicSamplingContext.cs b/src/Sentry/DynamicSamplingContext.cs index 34079d68ab..1979ecdb8d 100644 --- a/src/Sentry/DynamicSamplingContext.cs +++ b/src/Sentry/DynamicSamplingContext.cs @@ -183,10 +183,28 @@ public void SetReplayId(IReplaySession? replaySession) return new DynamicSamplingContext(items); } - public static DynamicSamplingContext CreateFromTransaction(TransactionTracer transaction, SentryOptions options, IReplaySession? replaySession) + // Resolves the DSN public key required to build a DSC. Returns false when no DSN is configured + // (e.g. Spotlight-only mode), in which case there is no public key and therefore no DSC to create. + private static bool TryGetPublicKey(SentryOptions options, out string publicKey) { + if (string.IsNullOrWhiteSpace(options.Dsn) || Dsn.IsDisabled(options.Dsn)) + { + publicKey = string.Empty; + return false; + } + + publicKey = options.ParsedDsn.PublicKey; + return true; + } + + public static DynamicSamplingContext? CreateFromTransaction(TransactionTracer transaction, SentryOptions options, IReplaySession? replaySession) + { + if (!TryGetPublicKey(options, out var publicKey)) + { + return null; + } + // These should already be set on the transaction. - var publicKey = options.ParsedDsn.PublicKey; var traceId = transaction.TraceId; var sampled = transaction.IsSampled; var sampleRate = transaction.SampleRate!.Value; @@ -209,10 +227,14 @@ public static DynamicSamplingContext CreateFromTransaction(TransactionTracer tra orgId: options.GetEffectiveOrgId()); } - public static DynamicSamplingContext CreateFromUnsampledTransaction(UnsampledTransaction transaction, SentryOptions options, IReplaySession? replaySession) + public static DynamicSamplingContext? CreateFromUnsampledTransaction(UnsampledTransaction transaction, SentryOptions options, IReplaySession? replaySession) { + if (!TryGetPublicKey(options, out var publicKey)) + { + return null; + } + // These should already be set on the transaction. - var publicKey = options.ParsedDsn.PublicKey; var traceId = transaction.TraceId; var sampled = transaction.IsSampled; var sampleRate = transaction.SampleRate!.Value; @@ -235,10 +257,14 @@ public static DynamicSamplingContext CreateFromUnsampledTransaction(UnsampledTra orgId: options.GetEffectiveOrgId()); } - public static DynamicSamplingContext CreateFromPropagationContext(SentryPropagationContext propagationContext, SentryOptions options, IReplaySession? replaySession) + public static DynamicSamplingContext? CreateFromPropagationContext(SentryPropagationContext propagationContext, SentryOptions options, IReplaySession? replaySession) { + if (!TryGetPublicKey(options, out var publicKey)) + { + return null; + } + var traceId = propagationContext.TraceId; - var publicKey = options.ParsedDsn.PublicKey; var release = options.SettingLocator.GetRelease(); var environment = options.SettingLocator.GetEnvironment(); @@ -260,7 +286,10 @@ public static DynamicSamplingContext CreateFromPropagationContext(SentryPropagat { return null; } - var publicKey = options.ParsedDsn.PublicKey; + if (!TryGetPublicKey(options, out var publicKey)) + { + return null; + } var release = options.SettingLocator.GetRelease(); var environment = options.SettingLocator.GetEnvironment(); @@ -283,13 +312,13 @@ internal static class DynamicSamplingContextExtensions public static DynamicSamplingContext? CreateDynamicSamplingContext(this BaggageHeader baggage, IReplaySession? replaySession = null) => DynamicSamplingContext.CreateFromBaggageHeader(baggage, replaySession); - public static DynamicSamplingContext CreateDynamicSamplingContext(this TransactionTracer transaction, SentryOptions options, IReplaySession? replaySession) + public static DynamicSamplingContext? CreateDynamicSamplingContext(this TransactionTracer transaction, SentryOptions options, IReplaySession? replaySession) => DynamicSamplingContext.CreateFromTransaction(transaction, options, replaySession); - public static DynamicSamplingContext CreateDynamicSamplingContext(this UnsampledTransaction transaction, SentryOptions options, IReplaySession? replaySession) + public static DynamicSamplingContext? CreateDynamicSamplingContext(this UnsampledTransaction transaction, SentryOptions options, IReplaySession? replaySession) => DynamicSamplingContext.CreateFromUnsampledTransaction(transaction, options, replaySession); - public static DynamicSamplingContext CreateDynamicSamplingContext(this SentryPropagationContext propagationContext, SentryOptions options, IReplaySession? replaySession) + public static DynamicSamplingContext? CreateDynamicSamplingContext(this SentryPropagationContext propagationContext, SentryOptions options, IReplaySession? replaySession) => DynamicSamplingContext.CreateFromPropagationContext(propagationContext, options, replaySession); public static DynamicSamplingContext? CreateDynamicSamplingContext(this IExternalPropagationContext propagationContext, SentryOptions options, IReplaySession? replaySession) diff --git a/src/Sentry/Http/ISpotlightTransport.cs b/src/Sentry/Http/ISpotlightTransport.cs new file mode 100644 index 0000000000..1fa086a396 --- /dev/null +++ b/src/Sentry/Http/ISpotlightTransport.cs @@ -0,0 +1,12 @@ +namespace Sentry.Http; + +/// +/// Transport for sending pre-serialized envelopes to a Spotlight sidecar. +/// Accepts raw bytes (not objects) so that +/// serialization can happen synchronously on the caller's thread, eliminating race +/// conditions with the main pipeline that mutates the event after capture. +/// +internal interface ISpotlightTransport +{ + Task SendAsync(byte[] serializedEnvelope, CancellationToken cancellationToken = default); +} diff --git a/src/Sentry/Http/SpotlightHttpTransport.cs b/src/Sentry/Http/SpotlightHttpTransport.cs index 80ad6504d8..a4821888ed 100644 --- a/src/Sentry/Http/SpotlightHttpTransport.cs +++ b/src/Sentry/Http/SpotlightHttpTransport.cs @@ -1,75 +1,63 @@ using Sentry.Extensibility; using Sentry.Infrastructure; -using Sentry.Internal.Http; -using Sentry.Protocol.Envelopes; namespace Sentry.Http; -internal class SpotlightHttpTransport : HttpTransport +/// +/// A standalone transport that sends pre-serialized envelopes to a Spotlight sidecar. +/// This transport is independent of the main Sentry transport — it does not wrap or delegate to it. +/// Serialization happens upstream (in ) on the calling +/// thread, so this transport only handles the HTTP POST and backoff logic. +/// +internal class SpotlightHttpTransport : ISpotlightTransport { - private readonly ITransport _inner; private readonly SentryOptions _options; private readonly HttpClient _httpClient; private readonly Uri _spotlightUrl; - private readonly ISystemClock _clock; - private readonly ExponentialBackoff _backoff; + internal readonly ExponentialBackoff _backoff; // internal for testing - public SpotlightHttpTransport(ITransport inner, SentryOptions options, HttpClient httpClient, Uri spotlightUrl, ISystemClock clock) - : base(options, httpClient) + public SpotlightHttpTransport(SentryOptions options, HttpClient httpClient, Uri spotlightUrl, ISystemClock clock) { _options = options; _httpClient = httpClient; _spotlightUrl = spotlightUrl; - _inner = inner; - _clock = clock; _backoff = new ExponentialBackoff(clock); } - protected internal override HttpRequestMessage CreateRequest(Envelope envelope) + public async Task SendAsync(byte[] serializedEnvelope, CancellationToken cancellationToken = default) { - return new HttpRequestMessage + if (!_backoff.ShouldAttempt()) { - RequestUri = _spotlightUrl, - Method = HttpMethod.Post, - Content = new EnvelopeHttpContent(envelope, _options.DiagnosticLogger, _clock) - { Headers = { ContentType = MediaTypeHeaderValue.Parse("application/x-sentry-envelope") } } - }; - } - - public override async Task SendEnvelopeAsync(Envelope envelope, CancellationToken cancellationToken = default) - { - var sentryTask = _inner.SendEnvelopeAsync(envelope, cancellationToken); + return; + } - if (_backoff.ShouldAttempt()) + try { - try + using var content = new ByteArrayContent(serializedEnvelope); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/x-sentry-envelope"); + + using var request = new HttpRequestMessage { - // Send to spotlight - using var processedEnvelope = ProcessEnvelope(envelope); - if (processedEnvelope.Items.Count > 0) - { - using var request = CreateRequest(processedEnvelope); - using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - await HandleResponseAsync(response, processedEnvelope, cancellationToken).ConfigureAwait(false); + RequestUri = _spotlightUrl, + Method = HttpMethod.Post, + Content = content + }; - _backoff.RecordSuccess(); - } - } - catch (Exception e) + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + _backoff.RecordSuccess(); + } + catch (Exception e) + { + var failureCount = _backoff.RecordFailure(); + if (failureCount == 1) { - int failureCount = _backoff.RecordFailure(); - if (failureCount == 1) - { - _options.LogError(e, "Failed sending envelope to Spotlight."); - } + _options.LogError(e, "Failed sending envelope to Spotlight at {0}.", _spotlightUrl); } } - - // await the Sentry request before returning - await sentryTask.ConfigureAwait(false); } - private class ExponentialBackoff + internal class ExponentialBackoff { private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromSeconds(1); private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromSeconds(60); diff --git a/src/Sentry/Internal/Constants.cs b/src/Sentry/Internal/Constants.cs index f522d94a40..a3c3cb5c43 100644 --- a/src/Sentry/Internal/Constants.cs +++ b/src/Sentry/Internal/Constants.cs @@ -20,6 +20,11 @@ internal static class Constants /// public const string EnvironmentEnvironmentVariable = "SENTRY_ENVIRONMENT"; + /// + /// Sentry Spotlight environment variable. + /// + public const string SpotlightEnvironmentVariable = "SENTRY_SPOTLIGHT"; + /// /// Default Sentry environment setting. /// diff --git a/src/Sentry/Internal/Http/NoOpTransport.cs b/src/Sentry/Internal/Http/NoOpTransport.cs new file mode 100644 index 0000000000..182faa8c1f --- /dev/null +++ b/src/Sentry/Internal/Http/NoOpTransport.cs @@ -0,0 +1,15 @@ +using Sentry.Extensibility; +using Sentry.Protocol.Envelopes; + +namespace Sentry.Internal.Http; + +/// +/// A transport that discards all envelopes. Used when no DSN is configured (e.g. Spotlight-only mode). +/// +internal sealed class NoOpTransport : ITransport +{ + public static readonly NoOpTransport Instance = new(); + + public Task SendEnvelopeAsync(Envelope envelope, CancellationToken cancellationToken = default) + => Task.CompletedTask; +} diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index dade93b89f..2aa6526ee5 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -52,9 +52,9 @@ internal Hub( ISampleRandHelper? sampleRandHelper = null, BackpressureMonitor? backpressureMonitor = null) { - if (string.IsNullOrWhiteSpace(options.Dsn)) + if (string.IsNullOrWhiteSpace(options.Dsn) && !options.EnableSpotlight) { - const string msg = "Attempt to instantiate a Hub without a DSN."; + const string msg = "Attempt to instantiate a Hub without a DSN or Spotlight enabled."; options.LogFatal(msg); throw new InvalidOperationException(msg); } @@ -248,6 +248,23 @@ internal ITransactionTracer StartTransaction( if (isSampled is false) { + if (_options.EnableSpotlight) + { + // When Spotlight is enabled, use a full TransactionTracer even for sampled-out transactions + // so that span data is recorded and can be sent to Spotlight. The IsSampled=false flag + // ensures trace headers propagate the correct sampling decision, and the main Sentry + // pipeline will still drop this transaction. + var spotlightTx = new TransactionTracer(this, context) + { + IsSampled = false, + SampleRate = sampleRate, + SampleRand = sampleRand, + DynamicSamplingContext = dynamicSamplingContext + }; + spotlightTx.DynamicSamplingContext ??= spotlightTx.CreateDynamicSamplingContext(_options, _replaySession); + return spotlightTx; + } + var unsampledTransaction = new UnsampledTransaction(this, context) { SampleRate = sampleRate, @@ -330,7 +347,10 @@ public BaggageHeader GetBaggage() } var propagationContext = CurrentScope.PropagationContext; - return propagationContext.GetOrCreateDynamicSamplingContext(_options, _replaySession).ToBaggageHeader(); + // GetOrCreateDynamicSamplingContext returns null when there is no DSN (e.g. Spotlight-only mode); + // fall back to an empty baggage header in that case. + return propagationContext.GetOrCreateDynamicSamplingContext(_options, _replaySession)?.ToBaggageHeader() + ?? BaggageHeader.Create([]); } public W3CTraceparentHeader? GetTraceparentHeader() diff --git a/src/Sentry/Internal/SdkComposer.cs b/src/Sentry/Internal/SdkComposer.cs index 96a9e2015d..fc5a627f8a 100644 --- a/src/Sentry/Internal/SdkComposer.cs +++ b/src/Sentry/Internal/SdkComposer.cs @@ -15,9 +15,9 @@ public SdkComposer(SentryOptions options, BackpressureMonitor? backpressureMonit ArgumentNullException.ThrowIfNull(options); _options = options; - if (options.Dsn is null) + if (options.Dsn is null && !options.EnableSpotlight) { - throw new ArgumentException("No DSN defined in the SentryOptions"); + throw new ArgumentException("No DSN defined and Spotlight is disabled in the SentryOptions."); } _backpressureMonitor = backpressureMonitor; } @@ -26,30 +26,43 @@ private ITransport CreateTransport() { _options.LogDebug("Creating transport."); - // Start from either the transport given on options, or create a new HTTP transport. - var transport = _options.Transport ?? new LazyHttpTransport(_options, _backpressureMonitor); + ITransport transport; + var hasDsn = !string.IsNullOrWhiteSpace(_options.Dsn) && !Dsn.IsDisabled(_options.Dsn!); - // When a cache directory path is given, wrap the transport in a caching transport. - if (!string.IsNullOrWhiteSpace(_options.CacheDirectoryPath)) + if (hasDsn) { - _options.LogDebug("Cache directory path is specified."); + // Start from either the transport given on options, or create a new HTTP transport. + transport = _options.Transport ?? new LazyHttpTransport(_options, _backpressureMonitor); - if (_options.DisableFileWrite) + // When a cache directory path is given, wrap the transport in a caching transport. + if (!string.IsNullOrWhiteSpace(_options.CacheDirectoryPath)) { - _options.LogInfo("File write has been disabled via the options. Skipping caching transport creation."); + _options.LogDebug("Cache directory path is specified."); + + if (_options.DisableFileWrite) + { + _options.LogInfo("File write has been disabled via the options. Skipping caching transport creation."); + } + else + { + _options.LogDebug("File writing is enabled, wrapping transport in caching transport."); + transport = CachingTransport.Create(transport, _options); + } } else { - _options.LogDebug("File writing is enabled, wrapping transport in caching transport."); - transport = CachingTransport.Create(transport, _options); + _options.LogDebug("No cache directory path specified. Skipping caching transport creation."); } } else { - _options.LogDebug("No cache directory path specified. Skipping caching transport creation."); + // No DSN — use a no-op transport (e.g. Spotlight-only mode). + _options.LogDebug("No DSN configured. Using no-op transport for Sentry."); + transport = NoOpTransport.Instance; } - // Wrap the transport with the Spotlight one that double sends the envelope: Sentry + Spotlight + // Create a separate Spotlight transport when enabled. + // Unlike before, this is NOT a wrapper around the main transport — it sends independently. if (_options.EnableSpotlight) { var environment = _options.SettingLocator.GetEnvironment(true); @@ -70,7 +83,7 @@ You can set a different environment via SENTRY_ENVIRONMENT env var or programati { throw new InvalidOperationException("Invalid option for SpotlightUrl: " + _options.SpotlightUrl); } - transport = new SpotlightHttpTransport(transport, _options, _options.GetHttpClient(), spotlightUrl, SystemClock.Clock); + _options.SpotlightTransport = new SpotlightHttpTransport(_options, _options.GetHttpClient(), spotlightUrl, SystemClock.Clock); } // Always persist the transport on the options, so other places can pick it up where necessary. diff --git a/src/Sentry/Internal/SettingLocator.cs b/src/Sentry/Internal/SettingLocator.cs index 4555ac7333..c664cb35e8 100644 --- a/src/Sentry/Internal/SettingLocator.cs +++ b/src/Sentry/Internal/SettingLocator.cs @@ -31,7 +31,9 @@ public SettingLocator(SentryOptions options) * Except when already assigned, any non-null value resolved should be assigned to the SentryOptions property. */ - public string GetDsn() + public string GetDsn() => GetDsn(required: true); + + public string GetDsn(bool required) { // For DSN only @@ -60,6 +62,13 @@ public string GetDsn() // By conventions, skip this if the DSN is not `null` i.e. `string.Empty` if (_options.Dsn is null && dsn is null) { + if (!required) + { + // When DSN is not required (e.g. Spotlight-only mode), treat as disabled + _options.Dsn = string.Empty; + return string.Empty; + } + throw new ArgumentNullException("You must supply a DSN to use Sentry." + "To disable Sentry, pass an empty string: \"\"." + "See https://docs.sentry.io/platforms/dotnet/configuration/options/#dsn"); @@ -102,6 +111,83 @@ public string GetDsn() return environment; } + private static readonly HashSet TruthyValues = new(StringComparer.OrdinalIgnoreCase) + { "true", "t", "y", "yes", "on", "1" }; + + private static readonly HashSet FalsyValues = new(StringComparer.OrdinalIgnoreCase) + { "false", "f", "n", "no", "off", "0" }; + + /// + /// Resolves Spotlight configuration from environment variables and applies precedence rules per spec. + /// Must be called before so that is set. + /// + public void ResolveSpotlight() + { + // Per spec: config options override environment variables. + // If EnableSpotlight was explicitly set to false in config, nothing can override it. + if (_options.EnableSpotlightExplicitlySet && !_options.EnableSpotlight) + { + return; + } + + var envVar = GetEnvironmentVariable(Constants.SpotlightEnvironmentVariable)?.Trim(); + if (string.IsNullOrEmpty(envVar)) + { + return; + } + + if (FalsyValues.Contains(envVar)) + { + // Env var disables — but only if config didn't explicitly enable + if (!_options.EnableSpotlightExplicitlySet) + { + _options.LogDebug("Spotlight disabled via {0} environment variable.", Constants.SpotlightEnvironmentVariable); + } + else + { + _options.LogDebug("Spotlight {0} environment variable is '{1}' but EnableSpotlight was explicitly set in configuration. Config value takes precedence.", + Constants.SpotlightEnvironmentVariable, envVar); + } + return; + } + + if (TruthyValues.Contains(envVar)) + { + // Env var enables with default URL + if (!_options.EnableSpotlight) + { + _options.EnableSpotlight = true; + _options.LogDebug("Spotlight enabled via {0} environment variable.", Constants.SpotlightEnvironmentVariable); + } + // Per spec: config spotlight=true + env var URL → use env var URL. + // But here the env var is just truthy (not a URL), so nothing more to do. + return; + } + + // Any other non-empty string is treated as a custom URL. + // Per spec: if config specifies a string URL → override env var (with warning). + if (_options.SpotlightUrlExplicitlySet) + { + _options.LogWarning( + "Spotlight URL from {0} environment variable ('{1}') is being ignored " + + "because a custom SpotlightUrl was set in configuration ('{2}').", + Constants.SpotlightEnvironmentVariable, envVar, _options.SpotlightUrl); + // Still enable if not already enabled + if (!_options.EnableSpotlight) + { + _options.EnableSpotlight = true; + } + return; + } + + // Env var provides a URL: enable Spotlight and use it. + // Per spec: config spotlight=true + env var URL → use env var URL. + _options.EnableSpotlight = true; + _options.SpotlightUrl = envVar; + _options.LogDebug("Spotlight enabled via {0} environment variable with URL: {1}", + Constants.SpotlightEnvironmentVariable, envVar); + } + public string? GetRelease() { var release = _options.Release; diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 74f961081d..5ca6f02f02 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -1,4 +1,5 @@ using Sentry.Extensibility; +using Sentry.Http; using Sentry.Internal; using Sentry.Protocol.Envelopes; @@ -127,6 +128,13 @@ public SentryId CaptureFeedback(SentryFeedback feedback, out CaptureFeedbackResu var attachments = hint.Attachments.ToList(); var envelope = Envelope.FromFeedback(processedEvent, _options.DiagnosticLogger, attachments, scope.SessionUpdate); + + // Send feedback to Spotlight (shared envelope — don't dispose) + if (_options.SpotlightTransport is not null) + { + SendToSpotlight(envelope, ownsEnvelope: false); + } + if (CaptureEnvelope(envelope)) { result = CaptureFeedbackResult.Success; @@ -168,8 +176,11 @@ public void CaptureTransaction(SentryTransaction transaction, Scope? scope, Sent // Sampling decision MUST have been made at this point Debug.Assert(transaction.IsSampled is not null, "Attempt to capture transaction without sampling decision."); + var hasSpotlight = _options.SpotlightTransport is not null; var spanCount = transaction.Spans.Count + 1; // 1 for each span + 1 for the transaction itself - if (transaction.IsSampled is false) + + // Fast path: no Spotlight and sampled out → skip enrichment entirely + if (!hasSpotlight && transaction.IsSampled is false) { _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.SampleRate, DataCategory.Transaction); _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.SampleRate, DataCategory.Span, spanCount); @@ -188,6 +199,22 @@ public void CaptureTransaction(SentryTransaction transaction, Scope? scope, Sent _enricher.Apply(transaction); + // Send enriched transaction to Spotlight before any filtering/sampling/PII-redaction. + // Spotlight bypasses transaction processors, BeforeSendTransaction, sampling, and PII redaction per spec. + if (_options.SpotlightTransport is not null) + { + SendToSpotlight(Envelope.FromTransaction(transaction)); + } + + // Sentry sampling check (after Spotlight interception) + if (transaction.IsSampled is false) + { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.SampleRate, DataCategory.Transaction); + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.SampleRate, DataCategory.Span, spanCount); + _options.LogDebug("Transaction dropped by sampling."); + return; + } + var processedTransaction = transaction; foreach (var processor in scope.GetAllTransactionProcessors()) { @@ -353,6 +380,16 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) } } + // Send enriched event to Spotlight before any filtering/sampling/PII-redaction. + // Spotlight bypasses event processors, BeforeSend, sampling, and PII redaction per spec. + if (_options.SpotlightTransport is not null) + { + // The event is serialized twice (once for Spotlight, once for the main pipeline); buffer any + // single-use stream attachments first so both serializations read independent copies. + BufferSingleUseAttachmentsForSpotlight(hint); + SendToSpotlight(Envelope.FromEvent(@event, _options.DiagnosticLogger, hint.Attachments.ToList())); + } + if (SentryEventHelper.ProcessEvent(@event, scope.GetAllEventProcessors(), hint, _options, DataCategory.Error) is not { } processedEvent) { @@ -451,6 +488,112 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) return null; } + /// + /// When Spotlight is enabled, an event's attachments are serialized twice — once for the Spotlight + /// envelope and once for the main pipeline envelope. and + /// return a fresh stream per read and are safe, but any other + /// content (e.g. ) may be backed by a single-use stream that the + /// first serialization would consume/dispose, corrupting the second. Buffer those into memory once so + /// both serializations read independent copies. + /// + private void BufferSingleUseAttachmentsForSpotlight(SentryHint hint) + { + if (hint.Attachments.Count == 0) + { + return; + } + + var buffered = new List(hint.Attachments.Count); + var changed = false; + foreach (var attachment in hint.Attachments) + { + if (attachment.Content is ByteAttachmentContent or FileAttachmentContent) + { + buffered.Add(attachment); + continue; + } + + try + { + using var stream = attachment.Content.GetStream(); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + buffered.Add(new SentryAttachment( + attachment.Type, + new ByteAttachmentContent(ms.ToArray()), + attachment.FileName, + attachment.ContentType)); + changed = true; + } + catch (Exception e) + { + // Keep the original attachment so the main pipeline is unaffected. + _options.LogError(e, "Failed to buffer attachment '{0}' for Spotlight.", attachment.FileName); + buffered.Add(attachment); + } + } + + if (!changed) + { + return; + } + + hint.Attachments.Clear(); + foreach (var attachment in buffered) + { + hint.Attachments.Add(attachment); + } + } + + /// + /// Serializes the envelope synchronously on the calling thread to capture a snapshot of the + /// event/transaction data, then fire-and-forgets the HTTP POST with the immutable bytes. + /// This eliminates the race condition where the main pipeline mutates the event concurrently + /// with Spotlight serialization. + /// When is true, the envelope is disposed after serialization. + /// + private void SendToSpotlight(Envelope envelope, bool ownsEnvelope = true) + { + if (_options.SpotlightTransport is not { } transport) + { + if (ownsEnvelope) + { + envelope.Dispose(); + } + return; + } + + byte[] serialized; + try + { + using var ms = new MemoryStream(); + envelope.Serialize(ms, _options.DiagnosticLogger); + serialized = ms.ToArray(); + } + finally + { + if (ownsEnvelope) + { + envelope.Dispose(); + } + } + + _ = SendToSpotlightAsync(transport, serialized); + + static async Task SendToSpotlightAsync(ISpotlightTransport spotlightTransport, byte[] data) + { + try + { + await spotlightTransport.SendAsync(data).ConfigureAwait(false); + } + catch + { + // Spotlight failures never affect the main pipeline. + // SpotlightHttpTransport handles its own error logging internally. + } + } + } + /// public bool CaptureEnvelope(Envelope envelope) { diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index 187da749e0..0c82f32993 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -1339,12 +1339,25 @@ public bool JsonPreserveReferences [EditorBrowsable(EditorBrowsableState.Never)] public Func? AssemblyReader { get; set; } + internal const string DefaultSpotlightUrl = "http://localhost:8969/stream"; + + private string? _spotlightUrl; + /// /// The Spotlight URL. Defaults to http://localhost:8969/stream /// /// /// - public string SpotlightUrl { get; set; } = "http://localhost:8969/stream"; + // TODO: Change to `string?` default `null` in 7.0.0 + public string SpotlightUrl + { + get => _spotlightUrl ?? DefaultSpotlightUrl; + set => _spotlightUrl = value; + } + + internal bool SpotlightUrlExplicitlySet => _spotlightUrl is not null; + + private bool? _enableSpotlight; /// /// Whether to enable Spotlight for local development. @@ -1354,7 +1367,20 @@ public bool JsonPreserveReferences /// /// /// - public bool EnableSpotlight { get; set; } + // TODO: Change to `bool?` default `null` in 7.0.0 + public bool EnableSpotlight + { + get => _enableSpotlight ?? false; + set => _enableSpotlight = value; + } + + internal bool EnableSpotlightExplicitlySet => _enableSpotlight is not null; + + /// + /// The transport used to send pre-serialized envelopes to Spotlight. + /// Set internally by the SDK during initialization. + /// + internal ISpotlightTransport? SpotlightTransport { get; set; } internal SettingLocator SettingLocator { get; set; } diff --git a/src/Sentry/SentryPropagationContext.cs b/src/Sentry/SentryPropagationContext.cs index 38870215ca..eaa01d6ac4 100644 --- a/src/Sentry/SentryPropagationContext.cs +++ b/src/Sentry/SentryPropagationContext.cs @@ -11,7 +11,7 @@ internal class SentryPropagationContext public DynamicSamplingContext? DynamicSamplingContext { get; private set; } - public DynamicSamplingContext GetOrCreateDynamicSamplingContext(SentryOptions options, IReplaySession replaySession) + public DynamicSamplingContext? GetOrCreateDynamicSamplingContext(SentryOptions options, IReplaySession replaySession) { if (DynamicSamplingContext is null) { diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index a6e31c57af..0093a1a00c 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -35,22 +35,32 @@ internal static IHub InitHub(SentryOptions options) ProcessInfo.Instance ??= new ProcessInfo(options); - // Locate the DSN - var dsnString = options.SettingLocator.GetDsn(); + // Resolve Spotlight settings from environment variables before DSN resolution, + // so EnableSpotlight is set when we decide whether DSN is required. + options.SettingLocator.ResolveSpotlight(); - // If it's either explicitly disabled or we couldn't resolve the DSN - // from anywhere else, return a disabled hub. - if (Dsn.IsDisabled(dsnString)) + // Locate the DSN. When Spotlight is enabled, DSN is not required. + var dsnString = options.SettingLocator.GetDsn(required: !options.EnableSpotlight); + var hasDsn = !Dsn.IsDisabled(dsnString); + + if (!hasDsn && !options.EnableSpotlight) { options.LogWarning("Init called with an empty string as the DSN. Sentry SDK will be disabled."); return DisabledHub.Instance; } - // Validate DSN for an early exception in case it's malformed - var dsn = Dsn.Parse(dsnString); - if (dsn.SecretKey != null) + if (hasDsn) + { + // Validate DSN for an early exception in case it's malformed + var dsn = Dsn.Parse(dsnString); + if (dsn.SecretKey != null) + { + options.LogWarning("The provided DSN that contains a secret key. This is not required and will be ignored."); + } + } + else { - options.LogWarning("The provided DSN that contains a secret key. This is not required and will be ignored."); + options.LogInfo("No DSN provided. Sentry SDK will run in Spotlight-only mode."); } #pragma warning disable CS0162 // Unreachable code detected @@ -63,8 +73,8 @@ internal static IHub InitHub(SentryOptions options) #pragma warning restore 0162 #pragma warning restore CS0162 // Unreachable code detected - // Initialize native platform SDKs here - if (options.InitNativeSdks) + // Initialize native platform SDKs here (only when we have a DSN) + if (hasDsn && options.InitNativeSdks) { #if __IOS__ InitSentryCocoaSdk(options); diff --git a/src/Sentry/TransactionTracer.cs b/src/Sentry/TransactionTracer.cs index e4ce907607..3578bd455c 100644 --- a/src/Sentry/TransactionTracer.cs +++ b/src/Sentry/TransactionTracer.cs @@ -87,7 +87,7 @@ public SpanStatus? Status } /// - public bool? IsSampled => true; // Implicitly if we instantiate this class then the transaction is sampled in + public bool? IsSampled { get; internal set; } = true; /// /// The sample rate used for this transaction. diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index c669a97060..8be7eded9c 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -2927,6 +2927,90 @@ public void ContinueTrace_OrgIdOptionOverridesDsn() // Assert - should NOT continue because OrgId override (2) != baggage org_id (1) transactionContext.TraceId.Should().NotBe(incomingTraceId); } + + [Fact] + public void StartTransaction_SampledOut_SpotlightEnabled_ReturnsTransactionTracer() + { + // Arrange + _fixture.Options.TracesSampleRate = 0.0; + _fixture.Options.EnableSpotlight = true; + var hub = _fixture.GetSut(); + + // Act + var transaction = hub.StartTransaction("name", "operation"); + + // Assert — returns TransactionTracer so span data is recorded for Spotlight + transaction.Should().BeOfType(); + transaction.IsSampled.Should().BeFalse(); + } + + [Fact] + public void StartTransaction_SampledOut_SpotlightDisabled_ReturnsUnsampledTransaction() + { + // Arrange + _fixture.Options.TracesSampleRate = 0.0; + _fixture.Options.EnableSpotlight = false; + var hub = _fixture.GetSut(); + + // Act + var transaction = hub.StartTransaction("name", "operation"); + + // Assert — returns lightweight UnsampledTransaction when Spotlight is off + transaction.Should().BeOfType(); + transaction.IsSampled.Should().BeFalse(); + } + + [Fact] + public void StartTransaction_SampledIn_SpotlightEnabled_ReturnsTransactionTracer() + { + // Arrange + _fixture.Options.TracesSampleRate = 1.0; + _fixture.Options.EnableSpotlight = true; + var hub = _fixture.GetSut(); + + // Act + var transaction = hub.StartTransaction("name", "operation"); + + // Assert — sampled-in always returns TransactionTracer regardless of Spotlight + transaction.Should().BeOfType(); + transaction.IsSampled.Should().BeTrue(); + } + + [Fact] + public void StartTransaction_SpotlightEnabledNoDsn_SampledIn_DoesNotThrow() + { + // Arrange — DSN-less Spotlight mode (SettingLocator resolves the DSN to an empty string). + // Creating the DynamicSamplingContext must not dereference the (absent) DSN. + _fixture.Options.Dsn = string.Empty; + _fixture.Options.EnableSpotlight = true; + _fixture.Options.TracesSampleRate = 1.0; + var hub = _fixture.GetSut(); + + // Act + var transaction = hub.StartTransaction("name", "operation"); + + // Assert + transaction.Should().BeOfType(); + transaction.IsSampled.Should().BeTrue(); + } + + [Fact] + public void StartTransaction_SpotlightEnabledNoDsn_SampledOut_DoesNotThrow() + { + // Arrange — DSN-less Spotlight mode, sampled out: still a TransactionTracer for Spotlight, + // and the DynamicSamplingContext creation must not dereference the (absent) DSN. + _fixture.Options.Dsn = string.Empty; + _fixture.Options.EnableSpotlight = true; + _fixture.Options.TracesSampleRate = 0.0; + var hub = _fixture.GetSut(); + + // Act + var transaction = hub.StartTransaction("name", "operation"); + + // Assert + transaction.Should().BeOfType(); + transaction.IsSampled.Should().BeFalse(); + } } #if NET6_0_OR_GREATER diff --git a/test/Sentry.Tests/Internals/Http/SpotlightTransportTests.cs b/test/Sentry.Tests/Internals/Http/SpotlightTransportTests.cs index d95cf86f08..4e37e7852c 100644 --- a/test/Sentry.Tests/Internals/Http/SpotlightTransportTests.cs +++ b/test/Sentry.Tests/Internals/Http/SpotlightTransportTests.cs @@ -6,22 +6,17 @@ namespace Sentry.Tests.Internals.Http; public class SpotlightTransportTests { private static readonly DateTimeOffset StartTime = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); + private static readonly Uri SpotlightUrl = new("http://localhost:8969/stream"); + private static readonly byte[] TestPayload = "test-envelope-payload"u8.ToArray(); private class Fixture { public MockClock Clock { get; } = new(StartTime); public MockableHttpMessageHandler Handler { get; } = Substitute.For(); public InMemoryDiagnosticLogger Logger { get; } = new(); - public ITransport InnerTransport { get; } = Substitute.For(); private bool _shouldFail; - public Fixture() - { - InnerTransport.SendEnvelopeAsync(Arg.Any(), Arg.Any()) - .Returns(Task.CompletedTask); - } - public void ConfigureHandlerToThrow(Exception exception = null) { Handler.WhenForAnyArgs(h => h.VerifiableSendAsync(null!, CancellationToken.None)) @@ -46,7 +41,7 @@ public int SpotlightErrorCount() { return Logger.Entries.Count(e => e.Level == SentryLevel.Error && - e.Message == "Failed sending envelope to Spotlight."); + e.Message.Contains("Failed sending envelope to Spotlight")); } public SpotlightHttpTransport GetSut() @@ -58,52 +53,16 @@ public SpotlightHttpTransport GetSut() DiagnosticLogger = Logger }; return new SpotlightHttpTransport( - InnerTransport, options, new HttpClient(Handler), - new Uri("http://localhost:8969/stream"), + SpotlightUrl, Clock); } } - // Makes sure it'll call both inner transport and spotlight, even if spotlight's request fails. - // Inner transport error actually bubbles up instead of Spotlights' - [Fact] - public async Task SendEnvelopeAsync_SpotlightRequestFailed_InnerTransportFailureBubblesUp() - { - // Arrange - var fixture = new Fixture(); - var expectedSpotlightTransportException = new Exception("Spotlight request fails"); - fixture.ConfigureHandlerToThrow(expectedSpotlightTransportException); - var sut = fixture.GetSut(); - - var envelope = Envelope.FromEvent(new SentryEvent()); - var expectedInnerTransportException = new Exception("expected inner transport exception"); - var tcs = new TaskCompletionSource(); - tcs.SetException(expectedInnerTransportException); - fixture.InnerTransport.SendEnvelopeAsync(envelope).Returns(tcs.Task); - - // Act - var actualException = await Assert.ThrowsAsync(() => sut.SendEnvelopeAsync(envelope)); - - // Assert - // Inner transport Exception bubbles out - Assert.Same(expectedInnerTransportException, actualException); - - // Spotlight request failure logged out to diagnostic logger - fixture.Logger.Entries.Any(e => - e.Level == SentryLevel.Error && - e.Message == "Failed sending envelope to Spotlight." && - ReferenceEquals(expectedSpotlightTransportException, e.Exception) - ).Should().BeTrue(); - } - - // Test error handling and backoff logic - // Ref: https://develop.sentry.dev/sdk/foundations/client/integrations/spotlight/#error-handling - // Spec: Unreachable Server — SDKs "MUST log an error message at least once" [Fact] - public async Task SendEnvelopeAsync_FirstFailure_LogsError() + public async Task SendAsync_FirstFailure_LogsError() { // Arrange var fixture = new Fixture(); @@ -111,7 +70,7 @@ public async Task SendEnvelopeAsync_FirstFailure_LogsError() var sut = fixture.GetSut(); // Act - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); // Assert fixture.SpotlightErrorCount().Should().Be(1); @@ -120,7 +79,7 @@ public async Task SendEnvelopeAsync_FirstFailure_LogsError() // Spec: Unreachable Server — SDKs "MUST NOT log an error message for every failed envelope" // Spec: Logging — "MUST avoid logging errors for every failed envelope to prevent log spam" [Fact] - public async Task SendEnvelopeAsync_SecondFailureAfterBackoff_DoesNotLogAgain() + public async Task SendAsync_SecondFailureAfterBackoff_DoesNotLogAgain() { // Arrange var fixture = new Fixture(); @@ -128,21 +87,20 @@ public async Task SendEnvelopeAsync_SecondFailureAfterBackoff_DoesNotLogAgain() var sut = fixture.GetSut(); // Act — first failure (logs error, sets backoff to 1s) - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); // Advance past backoff so second attempt is made fixture.Clock.SetUtcNow(StartTime + TimeSpan.FromSeconds(2)); - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); // Assert — still only one error logged fixture.SpotlightErrorCount().Should().Be(1); } // Spec: Unreachable Server — SDKs "SHOULD implement exponential backoff retry logic" - // Spec: "Spotlight transmission MUST never block normal Sentry operation." - // Verifies sends are skipped during backoff, but inner transport still runs. + // Verifies sends are skipped during backoff. [Fact] - public async Task SendEnvelopeAsync_DuringBackoffPeriod_SkipsSpotlightSend() + public async Task SendAsync_DuringBackoffPeriod_SkipsSpotlightSend() { // Arrange var fixture = new Fixture(); @@ -150,26 +108,23 @@ public async Task SendEnvelopeAsync_DuringBackoffPeriod_SkipsSpotlightSend() var sut = fixture.GetSut(); // First failure — sets backoff to 1s - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); // Advance only 500ms (still within 1s backoff) fixture.Clock.SetUtcNow(StartTime + TimeSpan.FromMilliseconds(500)); fixture.Handler.ClearReceivedCalls(); // Act - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); // Assert — Spotlight HTTP call was NOT made during backoff await fixture.Handler.DidNotReceiveWithAnyArgs().VerifiableSendAsync(null!, CancellationToken.None); - - // Inner transport was still called for both envelopes - await fixture.InnerTransport.Received(2).SendEnvelopeAsync(Arg.Any(), Arg.Any()); } // Spec: Unreachable Server — SDKs "SHOULD implement exponential backoff retry logic" // Verifies that after the backoff period expires, Spotlight send is retried on the next envelope. [Fact] - public async Task SendEnvelopeAsync_AfterBackoffExpires_RetriesSpotlightSend() + public async Task SendAsync_AfterBackoffExpires_RetriesSpotlightSend() { // Arrange var fixture = new Fixture(); @@ -177,14 +132,14 @@ public async Task SendEnvelopeAsync_AfterBackoffExpires_RetriesSpotlightSend() var sut = fixture.GetSut(); // First failure — sets backoff to 1s - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); // Advance past the 1s backoff fixture.Clock.SetUtcNow(StartTime + TimeSpan.FromSeconds(1.5)); fixture.Handler.ClearReceivedCalls(); // Act - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); // Assert — Spotlight HTTP call WAS made after backoff expired await fixture.Handler.ReceivedWithAnyArgs(1).VerifiableSendAsync(null!, CancellationToken.None); @@ -193,7 +148,7 @@ public async Task SendEnvelopeAsync_AfterBackoffExpires_RetriesSpotlightSend() // Spec: Unreachable Server — SDKs "SHOULD implement exponential backoff retry logic" // Verifies the doubling sequence: 1s -> 2s -> ... [Fact] - public async Task SendEnvelopeAsync_ConsecutiveFailures_BackoffDoubles() + public async Task SendAsync_ConsecutiveFailures_BackoffDoubles() { // Arrange var fixture = new Fixture(); @@ -201,11 +156,11 @@ public async Task SendEnvelopeAsync_ConsecutiveFailures_BackoffDoubles() var sut = fixture.GetSut(); // First failure — backoff = 1s - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); // Advance past 1s, second failure — backoff = 2s fixture.Clock.SetUtcNow(StartTime + TimeSpan.FromSeconds(1.5)); - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); // Advance only 1.5s more (total 3s from start, but need 2s from last failure at t=1.5s) // Last failure was at t=1.5s, backoff=2s, so retryAfter=3.5s @@ -213,7 +168,7 @@ public async Task SendEnvelopeAsync_ConsecutiveFailures_BackoffDoubles() fixture.Handler.ClearReceivedCalls(); // Act — should be skipped (3.0 < 3.5) - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); // Assert — still in backoff, no call made await fixture.Handler.DidNotReceiveWithAnyArgs().VerifiableSendAsync(null!, CancellationToken.None); @@ -222,14 +177,14 @@ public async Task SendEnvelopeAsync_ConsecutiveFailures_BackoffDoubles() fixture.Clock.SetUtcNow(StartTime + TimeSpan.FromSeconds(4)); fixture.Handler.ClearReceivedCalls(); - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); // Assert — call was made await fixture.Handler.ReceivedWithAnyArgs(1).VerifiableSendAsync(null!, CancellationToken.None); } [Fact] - public async Task SendEnvelopeAsync_BackoffCapsAtSixtySeconds() + public async Task SendAsync_BackoffCapsAtSixtySeconds() { // Arrange var fixture = new Fixture(); @@ -243,7 +198,7 @@ public async Task SendEnvelopeAsync_BackoffCapsAtSixtySeconds() var expectedDelays = new[] { 1, 2, 4, 8, 16, 32, 60 }; foreach (var delay in expectedDelays) { - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); // retryAfter was set to currentTime + delay inside the catch currentTime += TimeSpan.FromSeconds(delay) + TimeSpan.FromMilliseconds(100); fixture.Clock.SetUtcNow(currentTime); @@ -256,14 +211,14 @@ public async Task SendEnvelopeAsync_BackoffCapsAtSixtySeconds() fixture.Clock.SetUtcNow(StartTime + TimeSpan.FromSeconds(123)); fixture.Handler.ClearReceivedCalls(); - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); await fixture.Handler.DidNotReceiveWithAnyArgs().VerifiableSendAsync(null!, CancellationToken.None); // At 124s from start — should retry (proves cap at 60, not 64) fixture.Clock.SetUtcNow(StartTime + TimeSpan.FromSeconds(124)); fixture.Handler.ClearReceivedCalls(); - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); await fixture.Handler.ReceivedWithAnyArgs(1).VerifiableSendAsync(null!, CancellationToken.None); } @@ -272,7 +227,7 @@ public async Task SendEnvelopeAsync_BackoffCapsAtSixtySeconds() // Verifies that a successful send resets all backoff state (delay, log flag), so the next // failure is treated as a fresh first failure. [Fact] - public async Task SendEnvelopeAsync_SuccessAfterFailure_ResetsBackoffState() + public async Task SendAsync_SuccessAfterFailure_ResetsBackoffState() { // Arrange — use a flag to control handler behavior var fixture = new Fixture(); @@ -280,7 +235,7 @@ public async Task SendEnvelopeAsync_SuccessAfterFailure_ResetsBackoffState() var sut = fixture.GetSut(); // First failure — error logged, backoff = 1s - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); fixture.SpotlightErrorCount().Should().Be(1); // Advance past backoff, switch to success @@ -288,13 +243,13 @@ public async Task SendEnvelopeAsync_SuccessAfterFailure_ResetsBackoffState() fixture.SetShouldFail(false); // Success — resets all backoff state - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); // Switch back to failure fixture.SetShouldFail(true); // Act — fail again immediately (no backoff because state was reset) - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); // Assert — a second error IS logged (hasLoggedError was reset) fixture.SpotlightErrorCount().Should().Be(2); @@ -302,33 +257,20 @@ public async Task SendEnvelopeAsync_SuccessAfterFailure_ResetsBackoffState() // Verify backoff reset to 1s (not 2s): advance 1.5s should allow retry fixture.Clock.SetUtcNow(StartTime + TimeSpan.FromSeconds(4)); fixture.Handler.ClearReceivedCalls(); - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); + await sut.SendAsync(TestPayload); await fixture.Handler.ReceivedWithAnyArgs(1).VerifiableSendAsync(null!, CancellationToken.None); } - // Spec: "Spotlight transmission MUST never block normal Sentry operation." - // Spec: Unreachable Server — SDKs "MUST continue normal Sentry operation without interruption" - // Spec: "Spotlight failures MUST NOT affect event capture, transaction recording, or any - // other SDK functionality" - // Verifies inner transport is called for every envelope, even those skipped during backoff. + // Spotlight transport swallows all exceptions — never throws. [Fact] - public async Task SendEnvelopeAsync_SpotlightFails_InnerTransportAlwaysRuns() + public async Task SendAsync_SpotlightFailure_DoesNotThrow() { // Arrange var fixture = new Fixture(); - fixture.ConfigureHandlerToThrow(); + fixture.ConfigureHandlerToThrow(new Exception("catastrophic failure")); var sut = fixture.GetSut(); - // Act — send 3 envelopes: first fails, second during backoff, third after backoff - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); // fails, backoff=1s - - fixture.Clock.SetUtcNow(StartTime + TimeSpan.FromMilliseconds(500)); // during backoff - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); // skipped - - fixture.Clock.SetUtcNow(StartTime + TimeSpan.FromSeconds(2)); // after backoff - await sut.SendEnvelopeAsync(Envelope.FromEvent(new SentryEvent())); // retried, fails - - // Assert — inner transport was called for ALL 3 envelopes - await fixture.InnerTransport.Received(3).SendEnvelopeAsync(Arg.Any(), Arg.Any()); + // Act & Assert — no exception thrown + await sut.SendAsync(TestPayload); } } diff --git a/test/Sentry.Tests/Internals/SettingLocatorTests.cs b/test/Sentry.Tests/Internals/SettingLocatorTests.cs index 9491a410a9..dcaa01f170 100644 --- a/test/Sentry.Tests/Internals/SettingLocatorTests.cs +++ b/test/Sentry.Tests/Internals/SettingLocatorTests.cs @@ -304,6 +304,163 @@ public void GetEnvironment_CanOptOutOfDefault() Assert.Null(options.Environment); } + // --- ResolveSpotlight tests --- + + private const string SpotlightEnvironmentVariable = Constants.SpotlightEnvironmentVariable; + + [Theory] + [InlineData("true")] + [InlineData("1")] + [InlineData("yes")] + [InlineData("on")] + [InlineData("t")] + [InlineData("y")] + [InlineData("TRUE")] + [InlineData("Yes")] + public void ResolveSpotlight_TruthyEnvVar_EnablesSpotlightWithDefaultUrl(string value) + { + var options = new SentryOptions(); + options.FakeSettings().EnvironmentVariables[SpotlightEnvironmentVariable] = value; + + options.SettingLocator.ResolveSpotlight(); + + Assert.True(options.EnableSpotlight); + Assert.Equal(SentryOptions.DefaultSpotlightUrl, options.SpotlightUrl); + } + + [Theory] + [InlineData("false")] + [InlineData("0")] + [InlineData("no")] + [InlineData("off")] + [InlineData("f")] + [InlineData("n")] + public void ResolveSpotlight_FalsyEnvVar_DoesNotEnableSpotlight(string value) + { + var options = new SentryOptions(); + options.FakeSettings().EnvironmentVariables[SpotlightEnvironmentVariable] = value; + + options.SettingLocator.ResolveSpotlight(); + + Assert.False(options.EnableSpotlight); + } + + [Fact] + public void ResolveSpotlight_UrlEnvVar_EnablesWithCustomUrl() + { + var options = new SentryOptions(); + options.FakeSettings().EnvironmentVariables[SpotlightEnvironmentVariable] = "http://custom:1234/stream"; + + options.SettingLocator.ResolveSpotlight(); + + Assert.True(options.EnableSpotlight); + Assert.Equal("http://custom:1234/stream", options.SpotlightUrl); + } + + [Fact] + public void ResolveSpotlight_NoEnvVar_DoesNotChangeOptions() + { + var options = new SentryOptions(); + options.FakeSettings(); // no env vars set + + options.SettingLocator.ResolveSpotlight(); + + Assert.False(options.EnableSpotlight); + Assert.Equal(SentryOptions.DefaultSpotlightUrl, options.SpotlightUrl); + } + + [Fact] + public void ResolveSpotlight_ConfigExplicitlyDisabled_EnvVarIgnored() + { + var options = new SentryOptions { EnableSpotlight = false }; + options.FakeSettings().EnvironmentVariables[SpotlightEnvironmentVariable] = "true"; + + options.SettingLocator.ResolveSpotlight(); + + Assert.False(options.EnableSpotlight); + } + + [Fact] + public void ResolveSpotlight_ConfigExplicitlyDisabled_EnvVarUrlIgnored() + { + var options = new SentryOptions { EnableSpotlight = false }; + options.FakeSettings().EnvironmentVariables[SpotlightEnvironmentVariable] = "http://custom:1234/stream"; + + options.SettingLocator.ResolveSpotlight(); + + Assert.False(options.EnableSpotlight); + } + + [Theory] + [InlineData("false")] + [InlineData("0")] + [InlineData("no")] + public void ResolveSpotlight_ConfigEnabled_FalsyEnvVar_StaysEnabled(string value) + { + var options = new SentryOptions { EnableSpotlight = true }; + options.FakeSettings().EnvironmentVariables[SpotlightEnvironmentVariable] = value; + + options.SettingLocator.ResolveSpotlight(); + + // Config takes precedence over env var + Assert.True(options.EnableSpotlight); + } + + [Fact] + public void ResolveSpotlight_ConfigEnabledWithDefaultUrl_EnvVarProvidesUrl() + { + // Per spec: config spotlight=true + env var URL → use env var URL + var options = new SentryOptions { EnableSpotlight = true }; + options.FakeSettings().EnvironmentVariables[SpotlightEnvironmentVariable] = "http://custom:1234/stream"; + + options.SettingLocator.ResolveSpotlight(); + + Assert.True(options.EnableSpotlight); + Assert.Equal("http://custom:1234/stream", options.SpotlightUrl); + } + + [Fact] + public void ResolveSpotlight_ConfigExplicitUrl_EnvVarUrlIgnored() + { + // Per spec: if config specifies a string URL → config wins + var options = new SentryOptions + { + EnableSpotlight = true, + SpotlightUrl = "http://config:5555/stream" + }; + options.FakeSettings().EnvironmentVariables[SpotlightEnvironmentVariable] = "http://envvar:6666/stream"; + + options.SettingLocator.ResolveSpotlight(); + + Assert.True(options.EnableSpotlight); + Assert.Equal("http://config:5555/stream", options.SpotlightUrl); + } + + [Fact] + public void ResolveSpotlight_TruthyEnvVar_AlreadyEnabled_NoChange() + { + var options = new SentryOptions { EnableSpotlight = true }; + options.FakeSettings().EnvironmentVariables[SpotlightEnvironmentVariable] = "true"; + + options.SettingLocator.ResolveSpotlight(); + + Assert.True(options.EnableSpotlight); + Assert.Equal(SentryOptions.DefaultSpotlightUrl, options.SpotlightUrl); + } + + [Fact] + public void ResolveSpotlight_EnvVarWhitespace_TreatedAsEmpty() + { + var options = new SentryOptions(); + options.FakeSettings().EnvironmentVariables[SpotlightEnvironmentVariable] = " "; + + options.SettingLocator.ResolveSpotlight(); + + Assert.False(options.EnableSpotlight); + } + + // --- GetRelease tests --- + [Fact] public void GetRelease_WithEnvironmentVariable_ReturnsAndSetsRelease() { diff --git a/test/Sentry.Tests/SentryClientTests.Spotlight.cs b/test/Sentry.Tests/SentryClientTests.Spotlight.cs new file mode 100644 index 0000000000..e4e3599300 --- /dev/null +++ b/test/Sentry.Tests/SentryClientTests.Spotlight.cs @@ -0,0 +1,307 @@ +using Sentry.Http; +using Sentry.Internal; +using Sentry.Protocol.Envelopes; +using Sentry.Testing; + +namespace Sentry.Tests; + +public partial class SentryClientTests +{ + [Fact] + public void CaptureEvent_WithStreamAttachment_SpotlightEnabled_MainEnvelopeRetainsAttachment() + { + // Arrange — a stream-backed attachment shares a single-use stream between the Spotlight + // envelope and the main pipeline envelope. Spotlight must not consume/dispose it. + var spotlightTransport = Substitute.For(); + _fixture.SentryOptions.SpotlightTransport = spotlightTransport; + + const string attachmentContent = "SPOTLIGHT-ATTACHMENT-CONTENT"; + var scope = new Scope(_fixture.SentryOptions); + scope.AddAttachment(new SentryAttachment( + AttachmentType.Default, + new StreamAttachmentContent(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(attachmentContent))), + "attachment.txt", + null)); + + var sut = _fixture.GetSut(); + Envelope envelope = null; + sut.Worker.EnqueueEnvelope(Arg.Do(e => envelope = e)); + + // Act + sut.CaptureEvent(new SentryEvent(), scope); + + // Assert — the envelope the main pipeline sends still contains the attachment bytes intact + Assert.NotNull(envelope); + using var ms = new MemoryStream(); + envelope.Serialize(ms, null); + var serialized = System.Text.Encoding.UTF8.GetString(ms.ToArray()); + Assert.Contains(attachmentContent, serialized); + } + + [Fact] + public void CaptureEvent_DroppedByBeforeSend_StillSentToSpotlight() + { + // Arrange + var spotlightTransport = Substitute.For(); + _fixture.SentryOptions.SpotlightTransport = spotlightTransport; + _fixture.SentryOptions.SetBeforeSend((_, _) => null); // drop all events + + var sut = _fixture.GetSut(); + + // Act + var id = sut.CaptureEvent(new SentryEvent()); + + // Assert — main pipeline dropped the event + Assert.Equal(default, id); + _ = _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + + // Spotlight received the serialized envelope + spotlightTransport.Received(1).SendAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void CaptureEvent_DroppedByEventProcessor_StillSentToSpotlight() + { + // Arrange + var spotlightTransport = Substitute.For(); + _fixture.SentryOptions.SpotlightTransport = spotlightTransport; + + var processor = Substitute.For(); + processor.Process(Arg.Any()).ReturnsNull(); + _fixture.SentryOptions.AddEventProcessor(processor); + + var sut = _fixture.GetSut(); + + // Act + var id = sut.CaptureEvent(new SentryEvent()); + + // Assert — main pipeline dropped the event + Assert.Equal(default, id); + + // Spotlight received the serialized envelope + spotlightTransport.Received(1).SendAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void CaptureEvent_DroppedBySampling_StillSentToSpotlight() + { + // Arrange + var spotlightTransport = Substitute.For(); + _fixture.SentryOptions.SpotlightTransport = spotlightTransport; + _fixture.SentryOptions.SampleRate = 0.01f; // lowest valid rate + // Always return 0.99 so the event is always sampled out (0.99 >= 0.01) + _fixture.RandomValuesFactory = new FixedRandomValuesFactory(0.99); + + var sut = _fixture.GetSut(); + + // Act + var id = sut.CaptureEvent(new SentryEvent()); + + // Assert — main pipeline dropped the event + Assert.Equal(default, id); + + // Spotlight received the serialized envelope + spotlightTransport.Received(1).SendAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void CaptureTransaction_SampledOut_StillSentToSpotlight() + { + // Arrange + var spotlightTransport = Substitute.For(); + _fixture.SentryOptions.SpotlightTransport = spotlightTransport; + + var hub = Substitute.For(); + var transaction = new TransactionTracer(hub, "test name", "test operation") + { + IsSampled = false + }; + transaction.EndTimestamp = DateTimeOffset.Now; + + var sut = _fixture.GetSut(); + + // Act + sut.CaptureTransaction(new SentryTransaction(transaction)); + + // Assert — main pipeline dropped the transaction + _ = _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + + // Spotlight received the serialized envelope + spotlightTransport.Received(1).SendAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void CaptureTransaction_DroppedByBeforeSendTransaction_StillSentToSpotlight() + { + // Arrange + var spotlightTransport = Substitute.For(); + _fixture.SentryOptions.SpotlightTransport = spotlightTransport; + _fixture.SentryOptions.SetBeforeSendTransaction((_, _) => null); // drop all transactions + + var transaction = new SentryTransaction("test name", "test operation") + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now + }; + + var sut = _fixture.GetSut(); + + // Act + sut.CaptureTransaction(transaction); + + // Assert — main pipeline dropped the transaction + _ = _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + + // Spotlight received the serialized envelope + spotlightTransport.Received(1).SendAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void CaptureEvent_NoSpotlightTransport_DoesNotThrow() + { + // Arrange — no SpotlightTransport set (default null) + _fixture.SentryOptions.SpotlightTransport = null; + + var sut = _fixture.GetSut(); + + // Act & Assert — should not throw + var id = sut.CaptureEvent(new SentryEvent()); + + Assert.NotEqual(default, id); + } + + [Fact] + public void CaptureEvent_SpotlightFailure_DoesNotAffectMainPipeline() + { + // Arrange + var spotlightTransport = Substitute.For(); + spotlightTransport.SendAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new Exception("Spotlight is down"))); + _fixture.SentryOptions.SpotlightTransport = spotlightTransport; + + var sut = _fixture.GetSut(); + + // Act — should not throw even though Spotlight fails + var id = sut.CaptureEvent(new SentryEvent()); + + // Assert — main pipeline still processed the event + Assert.NotEqual(default, id); + _ = _fixture.BackgroundWorker.Received(1).EnqueueEnvelope(Arg.Any()); + } + + [Fact] + public void CaptureEvent_SpotlightData_DoesNotContainBeforeSendMutation() + { + // Arrange — capture the bytes Spotlight receives + byte[] capturedBytes = null; + var spotlightTransport = Substitute.For(); + spotlightTransport.SendAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask) + .AndDoes(ci => capturedBytes = ci.Arg()); + + _fixture.SentryOptions.SpotlightTransport = spotlightTransport; + _fixture.SentryOptions.SetBeforeSend((e, _) => + { + e.SetTag("mutated", "true"); + return e; + }); + + var sut = _fixture.GetSut(); + + // Act + sut.CaptureEvent(new SentryEvent()); + + // Assert — Spotlight received data that does NOT contain the BeforeSend mutation + Assert.NotNull(capturedBytes); + var payload = System.Text.Encoding.UTF8.GetString(capturedBytes); + Assert.DoesNotContain("mutated", payload); + } + + [Fact] + public void CaptureEvent_SpotlightData_DoesNotContainEventProcessorMutation() + { + // Arrange — capture the bytes Spotlight receives + byte[] capturedBytes = null; + var spotlightTransport = Substitute.For(); + spotlightTransport.SendAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask) + .AndDoes(ci => capturedBytes = ci.Arg()); + + _fixture.SentryOptions.SpotlightTransport = spotlightTransport; + + // Processor that adds a tag + var processor = Substitute.For(); + processor.Process(Arg.Any()).Returns(ci => + { + var evt = ci.Arg(); + evt.SetTag("processor-tag", "was-here"); + return evt; + }); + _fixture.SentryOptions.AddEventProcessor(processor); + + var sut = _fixture.GetSut(); + + // Act + sut.CaptureEvent(new SentryEvent()); + + // Assert — Spotlight received data that does NOT contain the processor mutation + Assert.NotNull(capturedBytes); + var payload = System.Text.Encoding.UTF8.GetString(capturedBytes); + Assert.DoesNotContain("processor-tag", payload); + } + + [Fact] + public void CaptureTransaction_SpotlightData_DoesNotContainProcessorMutation() + { + // Arrange — capture the bytes Spotlight receives + byte[] capturedBytes = null; + var spotlightTransport = Substitute.For(); + spotlightTransport.SendAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask) + .AndDoes(ci => capturedBytes = ci.Arg()); + + _fixture.SentryOptions.SpotlightTransport = spotlightTransport; + + // Transaction processor that adds a tag + var processor = Substitute.For(); + processor.Process(Arg.Any()).Returns(ci => + { + var tx = ci.Arg(); + tx.SetTag("tx-processor-tag", "was-here"); + return tx; + }); + _fixture.SentryOptions.AddTransactionProcessor(processor); + + var transaction = new SentryTransaction("test name", "test operation") + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now + }; + + var sut = _fixture.GetSut(); + + // Act + sut.CaptureTransaction(transaction); + + // Assert — Spotlight received data that does NOT contain the processor mutation + Assert.NotNull(capturedBytes); + var payload = System.Text.Encoding.UTF8.GetString(capturedBytes); + Assert.DoesNotContain("tx-processor-tag", payload); + } +} + +file class FixedRandomValuesFactory : RandomValuesFactory +{ + private readonly double _value; + + public FixedRandomValuesFactory(double value) => _value = value; + + public override int NextInt() => (int)(_value * int.MaxValue); + public override int NextInt(int minValue, int maxValue) => minValue; + public override double NextDouble() => _value; + public override void NextBytes(byte[] bytes) => Array.Fill(bytes, (byte)0); + +#if !(NETSTANDARD2_0 || NET462) + public override void NextBytes(Span bytes) => bytes.Fill(0); +#endif +} diff --git a/test/Sentry.Tests/SentrySdkTests.cs b/test/Sentry.Tests/SentrySdkTests.cs index 45750ebd93..f161cbc2b4 100644 --- a/test/Sentry.Tests/SentrySdkTests.cs +++ b/test/Sentry.Tests/SentrySdkTests.cs @@ -1202,6 +1202,73 @@ public void ProcessOnBeforeSend_EventProcessorsInvoked() } #endif + [SkippableFact] + public void Init_SpotlightEnabledNoDsn_SdkIsEnabled() + { +#if SENTRY_DSN_DEFINED_IN_ENV + Skip.If(true, "This test only works when the DSN is not configured as an environment variable."); +#endif + using var _ = SentrySdk.Init(o => + { + o.EnableSpotlight = true; + o.AutoSessionTracking = false; + o.BackgroundWorker = Substitute.For(); + o.InitNativeSdks = false; + }); + + Assert.True(SentrySdk.IsEnabled); + } + + [SkippableFact] + public void Init_SpotlightDisabledNoDsn_SdkIsDisabled() + { +#if SENTRY_DSN_DEFINED_IN_ENV + Skip.If(true, "This test only works when the DSN is not configured as an environment variable."); +#endif + // When Spotlight is off and no DSN is provided, the SDK is disabled via empty DSN + using var _ = SentrySdk.Init(o => + { + o.Dsn = string.Empty; + o.EnableSpotlight = false; + o.AutoSessionTracking = false; + o.InitNativeSdks = false; + }); + + Assert.False(SentrySdk.IsEnabled); + } + + [SkippableFact] + public void Init_SpotlightEnvVarNoDsn_SdkIsEnabled() + { +#if SENTRY_DSN_DEFINED_IN_ENV + Skip.If(true, "This test only works when the DSN is not configured as an environment variable."); +#endif + using var _ = SentrySdk.Init(o => + { + o.FakeSettings().EnvironmentVariables[SpotlightEnvironmentVariable] = "true"; + o.AutoSessionTracking = false; + o.BackgroundWorker = Substitute.For(); + o.InitNativeSdks = false; + }); + + Assert.True(SentrySdk.IsEnabled); + } + + [Fact] + public void Init_SpotlightEnabledWithDsn_SdkIsEnabled() + { + using var _ = SentrySdk.Init(o => + { + o.Dsn = ValidDsn; + o.EnableSpotlight = true; + o.AutoSessionTracking = false; + o.BackgroundWorker = Substitute.For(); + o.InitNativeSdks = false; + }); + + Assert.True(SentrySdk.IsEnabled); + } + public void Dispose() { SentrySdk.Close();