ref(options): Seal SentryOptions once Sentry.init has finished - #5999
Draft
runningcode wants to merge 3 commits into
Draft
ref(options): Seal SentryOptions once Sentry.init has finished#5999runningcode wants to merge 3 commits into
runningcode wants to merge 3 commits into
Conversation
SentryOptions is both the SDK's configuration object and its dependency injection container. Writes that arrive after init cannot take effect consistently, because collaborators have already been constructed from the values they read while being wired up. Add seal(), called at the end of Sentry.init once every integration has registered, and guard the 178 mutators on SentryOptions and SentryAndroidOptions against writes that arrive afterwards. A late write throws when debug is enabled so the mistake is loud during development, and is otherwise dropped with an error log rather than risking a crash in a host application. activate() is deliberately left where it is. It assembles the executor services, which initConfigurations needs in order to submit the profiling-traces cleanup and to start the backpressure monitor, so assembly has to precede the wiring even though sealing has to follow it. Three mutators are exempt, each documented at the setter. NdkIntegration downgrades enableNdk and enableScopeSync from register() when the native library fails to load, and SpotlightIntegration claims and releases beforeEnvelopeCallback from register() and close(). Both are runtime state rather than configuration and move out in a later change. SentryClient no longer writes its resolved transport factory back into the options; that write had no reader in main. This is the first step towards splitting the two concerns apart and making the configuration half immutable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📲 Install BuildsAndroid
|
Sentry.init supports being handed an options instance it has already initialized once — Sentry.java re-creates the executor services when a previous close() shut them down. The seal added in the previous commit broke that path: the second init could no longer write to the instance, so the SDK came back up with a closed executor service. Unseal at the start of init, so init opens the configuration phase and the seal at its end closes it again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Warning
Not ready for review. The seal as implemented is a behaviour change for anyone who configures options after
Sentry.initreturns — see Risk: late-bound options. That needs a decision before this lands.📜 Description
SentryOptionsis both the SDK's configuration object and its dependency injection container. This is the first step towards splitting those apart, and towards making the configuration half immutable.It adds a build→run boundary and enforces it:
SentryOptions.seal(), called at the end ofSentry.initonce every integration has registered. A single call there covers Android too, because the whole Android setup runs inside the init lambda.SentryOptions.unseal(), called at the start of init. The SDK supports being restarted with an options instance a previous init already sealed, so init reopens the configuration phase and the seal at its end closes it again.SentryOptions, 35 onSentryAndroidOptions— viaprotected rejectAfterSeal(String). A write that arrives after the seal throws whendebugis enabled, so the mistake is loud in development, and is otherwise dropped with an error log, so a late write from a third-party integration cannot crash a host application.SentryClientno longer writes its resolved transport factory back into the options. That write-back had no reader anywhere inmain.activate()is deliberately left where it is. It looked like the natural seal point — it is already@ApiStatus.Internaland already does container work — but it cannot be one. It fires before the user's config callback on Android (AndroidOptionsInitializer.java:145, inside the first of three phases), it fires mid-wiring on JVM (Sentry.java:332), and it runs twice on Android. It also cannot simply be moved to the end: it assembles the executor services, whichinitConfigurationsneeds in order to submit the profiling-traces cleanup (Sentry.java:650) and to start the backpressure monitor (:708). Assembly has to precede the wiring even though sealing has to follow it, so the two are separate hooks.The premise behind sealing is that a post-init write cannot take effect, because collaborators were already built from the value they read during init. That premise only holds for the options init consumes. A large set of options is read on the capture path instead, so writing to them after init works today and is observable:
beforeSend,beforeSendTransaction,beforeBreadcrumbSentryClientper capturesampleRate,tracesSampleRateSentryClient/TracesSamplerper capturerelease,environment,distMainEventProcessorper eventtags,maxBreadcrumbs,sendDefaultPii,ignoredErrorsSealing those turns a working pattern into a silent no-op (and a throw under
debug). That is a real behaviour change, and hybrid SDKs are the most likely place to hit it — settingrelease/distor abeforeSendfrom the JS/Dart layer after native init is a natural thing to do.Options for resolving this, in rough order of preference:
Exemptions
Three mutators are exempt, each documented at the setter. Both are runtime state rather than configuration, and both move out in a later change:
setEnableNdk,setEnableScopeSyncNdkIntegrationdowngrades these fromregister()when the native library fails to load;close()and the scope observers read the downgraded value.setBeforeEnvelopeCallbackSpotlightIntegrationclaims inregister()and releases inclose(), both after the seal.Known gaps
Deliberately out of scope here, listed so they aren't mistaken for oversights:
Logs,Metrics,Cron,Proxy) andDistributionOptions' public fields are not sealed, sogetLogs().setEnabled(...)still slips through.initForTestunseals, because ~13 existing fixtures configure options after standing the SDK up.SentryOptionsSealTestcovers the production behaviour directly instead.💡 Motivation and Context
For the options init consumes, mutating them afterwards is a silent no-op: the logger, serializer, executors, transport, profilers and span factory have all already been built from the values they read during init, so a later write changes the field without changing behaviour. Sealing turns that class of bug into a loud failure and establishes the invariant the rest of the separation depends on.
Enforcing it also surfaced every real post-init mutation in
main. There were exactly three, and each is now either fixed or documented as runtime state to be relocated — which is what makes the follow-up stages tractable.Follow-up stages, not in this PR: extract an internal
SdkComponentscontainer for the ~30 service fields; move derived state (parsedDsn, theLazyEvaluators,internalTracesSampler) onto it; then aSentryOptions.Builderwith final fields in a major version.💚 How did you test it?
SentryOptionsSealTestcovers the seal directly: writes apply before the seal, are ignored after it, throw after it when debug is on,unseal()restores writability, the exempt callback slot stays writable,Sentry.initseals the options it was given, and restarting the SDK with the same options instance reopens them for wiring.Full JVM suite (
./gradlew test) and Android unit tests pass.sentry-async-profiler's 16JavaContinuousProfilerTestfailures are pre-existing onmain— verified by stashing this branch.📝 Checklist
sendDefaultPIIis enabled.The API diff is additive only —
seal(),unseal(),protected rejectAfterSeal(String)— so it is binary compatible. The behavioural risk above is the thing to review, not the signature change.🔮 Next steps
Stage 2 extracts
SdkComponents. 12 of the 27 service setters are already@ApiStatus.Internal/@Experimentaland can move with no compat shim; the ~13 stable-public ones become nullable "declared override" fields the container reads while assembling.