Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/Sentry/Integrations/GlobalRootScopeIntegration.cs
Copy link
Copy Markdown
Collaborator Author

@jamescrosswell jamescrosswell Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could potentially set more stuff here. There are lots of scope properties that never change and which we currently set on the scope every time we capture an event. These are all candidates, I think:

Contexts.CopyTo(other.Contexts);
Request.CopyTo(other.Request);
User.CopyTo(other.User);
other.Release ??= Release;
other.Distribution ??= Distribution;
other.Environment ??= Environment;
other.TransactionName ??= TransactionName;
other.Level ??= Level;
if (Sdk.Name is not null && Sdk.Version is not null)
{
other.Sdk.Name = Sdk.Name;
other.Sdk.Version = Sdk.Version;
}
foreach (var package in Sdk.InternalPackages)
{
other.Sdk.AddPackage(package);
}

More of a performance improvement but might result in a subtle behavioural change for some users (perhaps code that checks/expects things to not be set in certain circumstances and sets them conditionally on this basis ). To be safe, perhaps we delay a change like that until the next major release.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Sentry.Integrations;

internal class GlobalRootScopeIntegration : ISdkIntegration
{
public void Register(IHub hub, SentryOptions options)
{
if (!options.IsGlobalModeEnabled)
{
return;
}

hub.ConfigureScope(scope => scope.User.Id ??= options.InstallationId);
}
}
14 changes: 9 additions & 5 deletions src/Sentry/Internal/Enricher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ internal class Enricher

private readonly Lazy<Runtime> _runtimeLazy = new(() =>
{
var current = PlatformAbstractions.SentryRuntime.Current;
var current = SentryRuntime.Current;
return new Runtime
{
Name = current.Name,
Expand All @@ -36,7 +36,7 @@ public void Apply(IEventLike eventLike)
if (!eventLike.Contexts.ContainsKey(OperatingSystem.Type))
{
// RuntimeInformation.OSDescription is throwing on Mono 5.12
if (!PlatformAbstractions.SentryRuntime.Current.IsMono())
if (!SentryRuntime.Current.IsMono())
{
#if NETFRAMEWORK
// RuntimeInformation.* throws on .NET Framework on macOS/Linux
Expand All @@ -58,9 +58,8 @@ public void Apply(IEventLike eventLike)
}
}

// SDK
// SDK Name/Version might have be already set by an outer package
// e.g: ASP.NET Core can set itself as the SDK
// e.g.: ASP.NET Core can set itself as the SDK
if (eventLike.Sdk.Version is null && eventLike.Sdk.Name is null)
{
eventLike.Sdk.Name = Constants.SdkName;
Expand Down Expand Up @@ -92,7 +91,12 @@ public void Apply(IEventLike eventLike)

eventLike.User.IpAddress ??= DefaultIpAddress;
}
eventLike.User.Id ??= _options.InstallationId;
// Set by the GlobalRootScopeIntegration in global mode so that it can be overridden by the user.
// In non-global mode (e.g. ASP.NET Core) the enricher sets it here as a fallback.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Flash0ver arguably we could remove this code entirely... not sure if we actually want to set the same user id for all ASP.NET requests (where no user was logged in).

@bruno-garcia any thoughts from your side?

if (!_options.IsGlobalModeEnabled)
{
eventLike.User.Id ??= _options.InstallationId;
}

//Apply App startup and Boot time
eventLike.Contexts.App.StartTime ??= ProcessInfo.Instance?.StartupTime;
Expand Down
3 changes: 2 additions & 1 deletion src/Sentry/PlatformAbstractions/FrameworkInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public static partial class FrameworkInfo
{528372, "4.8"},
{528449, "4.8"},
{533320, "4.8.1"},
{533325, "4.8.1"}
{533325, "4.8.1"},
{533509, "4.8.1"}
};
}
7 changes: 7 additions & 0 deletions src/Sentry/SentryOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ internal IEnumerable<ISdkIntegration> Integrations
}
#endif

if ((_defaultIntegrations & DefaultIntegrations.GlobalRootScopeIntegration) != 0)
{
yield return new GlobalRootScopeIntegration();
}

foreach (var integration in _integrations)
{
yield return integration;
Expand Down Expand Up @@ -1362,6 +1367,7 @@ public SentryOptions()
#if NET8_0_OR_GREATER
| DefaultIntegrations.SystemDiagnosticsMetricsIntegration
#endif
| DefaultIntegrations.GlobalRootScopeIntegration
;
Comment on lines 1367 to 1371
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GlobalRootScopeIntegration is now enabled by default, but unlike the other default integrations there’s no public Disable* method to let consumers turn it off. Consider adding a DisableGlobalRootScopeIntegration() (or similar) for parity and to give users a supported opt-out.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

@jamescrosswell jamescrosswell Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arguably it's not an 'integration'... it's just some behaviour we want on all global root scopes (there's no need for an opt-out). We could move it out of the integrations list and bake the logic into the Hub constructor instead.

Otherwise I think it's OK to simply omit the disable integration method for this one. @Flash0ver thoughts?


#if ANDROID
Expand Down Expand Up @@ -1829,6 +1835,7 @@ internal enum DefaultIntegrations
#if NET8_0_OR_GREATER
SystemDiagnosticsMetricsIntegration = 1 << 7,
#endif
GlobalRootScopeIntegration = 1 << 8,
}

internal void SetupLogging()
Expand Down
145 changes: 145 additions & 0 deletions test/Sentry.Tests/Integrations/GlobalRootScopeIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using Sentry.Integrations;

namespace Sentry.Tests.Integrations;

public class GlobalRootScopeIntegrationTests
{
[Fact]
public void Register_GlobalModeEnabled_SetsInstallationIdOnRootScope()
{
// Arrange
var options = new SentryOptions
{
Dsn = ValidDsn,
IsGlobalModeEnabled = true,
AutoSessionTracking = false
};

var hub = Substitute.For<IHub>();
var integration = new GlobalRootScopeIntegration();

// Act
integration.Register(hub, options);

// Assert
hub.Received(1).ConfigureScope(Arg.Any<Action<Scope>>());
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test name says it “SetsInstallationIdOnRootScope”, but the assertion only verifies that ConfigureScope is called. Either update the assertion to validate the configured action sets User.Id from InstallationId (similar to the later test), or rename this test to reflect what it actually checks.

Suggested change
hub.Received(1).ConfigureScope(Arg.Any<Action<Scope>>());
hub.Received(1).ConfigureScope(Arg.Do<Action<Scope>>(configure =>
{
var scope = new Scope(options);
configure(scope);
Assert.Equal(options.InstallationId, scope.User?.Id);
}));

Copilot uses AI. Check for mistakes.
}

[Fact]
public void Register_GlobalModeDisabled_DoesNotConfigureScope()
{
// Arrange
var options = new SentryOptions
{
Dsn = ValidDsn,
IsGlobalModeEnabled = false,
AutoSessionTracking = false
};

var hub = Substitute.For<IHub>();
var integration = new GlobalRootScopeIntegration();

// Act
integration.Register(hub, options);

// Assert
hub.DidNotReceive().ConfigureScope(Arg.Any<Action<Scope>>());
}

[Fact]
public void Register_GlobalModeEnabled_SetsUserIdFromInstallationId()
{
// Arrange
var options = new SentryOptions
{
Dsn = ValidDsn,
IsGlobalModeEnabled = true,
AutoSessionTracking = false
};

// Capture the action passed to ConfigureScope
Action<Scope> capturedAction = null;
var hub = Substitute.For<IHub>();
hub.When(h => h.ConfigureScope(Arg.Any<Action<Scope>>()))
.Do(call => capturedAction = call.Arg<Action<Scope>>());

var integration = new GlobalRootScopeIntegration();
integration.Register(hub, options);

// Apply the captured action to a real scope
var scope = new Scope(options);

capturedAction.Should().NotBeNull();
capturedAction(scope);

// The scope's User.Id should be set to the InstallationId
scope.User.Id.Should().Be(options.InstallationId);
}

[Fact]
public void Register_GlobalModeEnabled_DoesNotOverwriteExistingUserId()
{
// Arrange
var options = new SentryOptions
{
Dsn = ValidDsn,
IsGlobalModeEnabled = true,
AutoSessionTracking = false
};

// Capture the action passed to ConfigureScope
Action<Scope> capturedAction = null;
var hub = Substitute.For<IHub>();
hub.When(h => h.ConfigureScope(Arg.Any<Action<Scope>>()))
.Do(call => capturedAction = call.Arg<Action<Scope>>());

var integration = new GlobalRootScopeIntegration();
integration.Register(hub, options);

// Apply the captured action to a scope that already has a User.Id
var scope = new Scope(options);
const string existingUserId = "my-custom-user-id";
scope.User.Id = existingUserId;

capturedAction.Should().NotBeNull();
capturedAction(scope);

// The existing User.Id should not be overwritten
scope.User.Id.Should().Be(existingUserId);
}

[Fact]
public void Enricher_GlobalModeEnabled_DoesNotSetInstallationId()
{
// Verify the enricher no longer sets User.Id when global mode is enabled,
// ensuring users can clear the User.Id set by GlobalRootScopeIntegration.
var options = new SentryOptions { IsGlobalModeEnabled = true };
var enricher = new Sentry.Internal.Enricher(options);

var eventLike = Substitute.For<IEventLike>();
eventLike.Sdk.Returns(new SdkVersion());
eventLike.User = new SentryUser();
eventLike.Contexts = new SentryContexts();

enricher.Apply(eventLike);

eventLike.User.Id.Should().BeNull();
}

[Fact]
public void Enricher_GlobalModeDisabled_SetsInstallationIdAsFallback()
{
// Verify the enricher still sets User.Id when global mode is disabled (e.g. ASP.NET Core).
var options = new SentryOptions { IsGlobalModeEnabled = false };
var enricher = new Sentry.Internal.Enricher(options);

var eventLike = Substitute.For<IEventLike>();
eventLike.Sdk.Returns(new SdkVersion());
eventLike.User = new SentryUser();
eventLike.Contexts = new SentryContexts();

enricher.Apply(eventLike);

eventLike.User.Id.Should().Be(options.InstallationId);
}
}
19 changes: 16 additions & 3 deletions test/Sentry.Tests/SentryClientTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using NSubstitute.ReceivedExtensions;
using Sentry.Internal.Http;
using BackgroundWorker = Sentry.Internal.BackgroundWorker;

Expand Down Expand Up @@ -302,10 +301,17 @@ public void CaptureEvent_EventAndScope_CopyScopeIntoEvent()
Assert.Equal(scope.Breadcrumbs, @event.Breadcrumbs);
}

[Fact]
[SkippableFact]
public void CaptureEvent_UserIsNull_SetsFallbackUserId()
{
#if NET5_0_OR_GREATER
// In global mode the userid gets set at app startup via the GlobalRootScopeIntegration, rather than by an
// enricher during capture... so this functionality in SentryClient only works when IsGlobalModeEnabled is false
Skip.If(System.OperatingSystem.IsAndroid() || System.OperatingSystem.IsIOS(),
"On mobile, User.Id is set by GlobalRootScopeIntegration at startup, not the enricher.");
#endif
// Arrange
_fixture.SentryOptions.IsGlobalModeEnabled = false;
var scope = new Scope(_fixture.SentryOptions);
var @event = new SentryEvent();

Expand Down Expand Up @@ -1293,10 +1299,17 @@ public void CaptureTransaction_ScopeContainsAttachments_GetAppliedToHint()
hint.Attachments.Should().Contain(attachments);
}

[Fact]
[SkippableFact]
public void CaptureTransaction_UserIsNull_SetsFallbackUserId()
{
#if NET5_0_OR_GREATER
Skip.If(System.OperatingSystem.IsAndroid() || System.OperatingSystem.IsIOS(),
"On mobile, User.Id is set by GlobalRootScopeIntegration at startup, not the enricher.");
#endif
// Arrange
// In global mode the userid gets set at app startup via the GlobalRootScopeIntegration, rather than by an
// enricher during capture... so this functionality in SentryClient only works when IsGlobalModeEnabled is false
_fixture.SentryOptions.IsGlobalModeEnabled = false;
var transaction = new SentryTransaction("name", "operation")
{
IsSampled = true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
[
{
Message: Initializing Hub for Dsn: '{0}'.,
Args: [
https://d4d82fc1c2c4032a83f3a29aa3a3aff@fake-sentry.io:65535/2147483647
]
},
{
Message: Starting BackpressureMonitor.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These were never really part of the test anyway (which is to ensure all the default integrations get registered)

},
{
Message: Registering integration: '{0}'.,
Args: [
Expand Down Expand Up @@ -37,5 +28,11 @@
Args: [
SentryDiagnosticListenerIntegration
]
},
{
Message: Registering integration: '{0}'.,
Args: [
GlobalRootScopeIntegration
]
}
]
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
[
{
Message: Initializing Hub for Dsn: '{0}'.,
Args: [
https://d4d82fc1c2c4032a83f3a29aa3a3aff@fake-sentry.io:65535/2147483647
]
},
{
Message: Starting BackpressureMonitor.
},
{
Message: Registering integration: '{0}'.,
Args: [
Expand Down Expand Up @@ -43,5 +34,11 @@
Args: [
WinUIUnhandledExceptionIntegration
]
},
{
Message: Registering integration: '{0}'.,
Args: [
GlobalRootScopeIntegration
]
}
]
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
[
{
Message: Initializing Hub for Dsn: '{0}'.,
Args: [
https://d4d82fc1c2c4032a83f3a29aa3a3aff@fake-sentry.io:65535/2147483647
]
},
{
Message: Starting BackpressureMonitor.
},
{
Message: Registering integration: '{0}'.,
Args: [
Expand Down Expand Up @@ -37,5 +28,11 @@
Args: [
SentryDiagnosticListenerIntegration
]
},
{
Message: Registering integration: '{0}'.,
Args: [
GlobalRootScopeIntegration
]
}
]
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
[
{
Message: Initializing Hub for Dsn: '{0}'.,
Args: [
https://d4d82fc1c2c4032a83f3a29aa3a3aff@fake-sentry.io:65535/2147483647
]
},
{
Message: Starting BackpressureMonitor.
},
{
Message: Registering integration: '{0}'.,
Args: [
Expand Down Expand Up @@ -43,5 +34,11 @@
Args: [
WinUIUnhandledExceptionIntegration
]
},
{
Message: Registering integration: '{0}'.,
Args: [
GlobalRootScopeIntegration
]
}
]
Loading
Loading