Skip to content

ref(options): Seal SentryOptions once Sentry.init has finished - #5999

Draft
runningcode wants to merge 3 commits into
mainfrom
no/seal-sentry-options-after-activate
Draft

ref(options): Seal SentryOptions once Sentry.init has finished#5999
runningcode wants to merge 3 commits into
mainfrom
no/seal-sentry-options-after-activate

Conversation

@runningcode

@runningcode runningcode commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Warning

Not ready for review. The seal as implemented is a behaviour change for anyone who configures options after Sentry.init returns — see Risk: late-bound options. That needs a decision before this lands.

📜 Description

SentryOptions is 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 of Sentry.init once 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.
  • 178 mutators guarded — 143 on SentryOptions, 35 on SentryAndroidOptions — via protected rejectAfterSeal(String). A write that arrives after the seal throws when debug is 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.
  • SentryClient no longer writes its resolved transport factory back into the options. That write-back had no reader anywhere in main.

activate() is deliberately left where it is. It looked like the natural seal point — it is already @ApiStatus.Internal and 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, which initConfigurations needs 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.

⚠️ Risk: late-bound options

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:

Option Read at
beforeSend, beforeSendTransaction, beforeBreadcrumb SentryClient per capture
sampleRate, tracesSampleRate SentryClient / TracesSampler per capture
release, environment, dist MainEventProcessor per event
tags, maxBreadcrumbs, sendDefaultPii, ignoredErrors scope/client per capture

Sealing 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 — setting release/dist or a beforeSend from the JS/Dart layer after native init is a natural thing to do.

Options for resolving this, in rough order of preference:

  1. Narrow the seal to options init consumes — services, executors, cache dirs, loaders. Zero behaviour change, and it still catches the DI-container class of bug this PR is actually about. Late-bound value options stay writable until there is a supported scope-based alternative to migrate to.
  2. Log-only for one release, no throw, then enforce in a major. Standard migration path, but leaves the invariant unenforced in the meantime.
  3. Keep the full seal and coordinate with hybrid SDKs first.

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:

Mutator Why
setEnableNdk, setEnableScopeSync NdkIntegration downgrades these from register() when the native library fails to load; close() and the scope observers read the downgraded value.
setBeforeEnvelopeCallback Single-slot listener registry that SpotlightIntegration claims in register() and releases in close(), both after the seal.

Known gaps

Deliberately out of scope here, listed so they aren't mistaken for oversights:

  • The nested options classes (Logs, Metrics, Cron, Proxy) and DistributionOptions' public fields are not sealed, so getLogs().setEnabled(...) still slips through.
  • initForTest unseals, because ~13 existing fixtures configure options after standing the SDK up. SentryOptionsSealTest covers 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 SdkComponents container for the ~30 service fields; move derived state (parsedDsn, the LazyEvaluators, internalTracesSampler) onto it; then a SentryOptions.Builder with final fields in a major version.

💚 How did you test it?

SentryOptionsSealTest covers 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.init seals 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 16 JavaContinuousProfilerTest failures are pre-existing on main — verified by stashing this branch.

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.
  • Public API changes reviewed by another Mobile SDK team member or implemented according to the develop docs spec.

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/@Experimental and can move with no compat shim; the ~13 stable-public ones become nullable "declared override" fields the container reads while assembling.

runningcode and others added 2 commits August 25, 2026 18:24
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>
@sentry

sentry Bot commented Aug 25, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.53.0 (1) release

⚙️ sentry-android Build Distribution Settings

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant