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();