Skip to content

Commit 6805115

Browse files
isaacsclaude
andauthored
feat(core): Warn on repeated Sentry.init() and unbind the client on close() (#24962)
A repeated `Sentry.init()` call was mostly undefined behavior, and each SDK handled it in its own way. Most SDKs built a new client and replaced the old one without a warning. Nothing closed the old client, so its buffers, timers, and hooks stayed alive, and `setupOnce` kept the settings of the first call. The goal is one rule for all SDKs: the first `init()` wins, a later call returns the active client, and `close()` lets you start over. Most SDKs cannot switch to "first wins" before a major version, so this change adds the parts that are safe now: - `initAndBind`, Node's `_init`, and Vercel Edge's `init` print a warning (with or without `debug`) when a client is already bound. They still replace the client for now. Wrappers that expect a repeated call (Next.js server, Remix server, Nuxt server, Hono) keep their own guard and return early, so they do not warn. Cloudflare's `cacheClient: false` asks for a new client on each call, so it unbinds the old one first and does not warn. The Next.js client drops its own warning, which used a flag that never reset and so also fired after `close()`. Its config-file hint moves to the docs. - `Sentry.close()` unbinds the client after it closes it. Before, a guard based on `getClient()` treated the closed client as active, so `close(); init()` returned the closed client. Nuxt's server guard now also checks for a bound client, for the same reason. Cloudflare also caches its client for the isolate, and the cache handed the closed client back to every later `init()`, so the isolate sent nothing until it was recycled. Closing the cached client now clears the cache. - Next.js server and Remix server return the active client from a repeated call, not `undefined`. A caller could not tell "already initialized" from "failed". The Next.js and Nuxt server `init()` added their event processors to the global scope. Since `close()` now unbinds the client, a later `init()` would run the full setup again, and each cycle would add another copy of each processor to the global scope. So instead, add them to the client, so that they go away with a closed client, and a later `init()` uses its own options. Client processors run before all scope processors, so these now also run before any global scope processor that user code added before `init()`. They only drop events or fix stack frames, so the earlier position does not change which events are sent. `docs/repeated-init.md` records the rule, the current behavior of each SDK, and the plan for the next major. The warning text should point apps that share a page to the isolated client helper from #24883 once that helper has a final name. Fixes #24960 --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent d93e210 commit 6805115

68 files changed

Lines changed: 859 additions & 126 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.agents/skills/write-tests/SKILL.md‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -307,16 +307,31 @@ it.each([
307307
### Test isolation
308308

309309
Tests must never depend on execution order or share mutable state. For this codebase, many tests
310-
need to reset global Sentry state:
310+
need to reset global Sentry state. Use whatever reset the existing tests in your package use (many
311+
packages have a `resetSdk()` or `cleanupOtel()` helper). Otherwise, pick one:
311312

312313
```typescript
313-
beforeEach(() => {
314-
clearGlobalScope();
315-
getCurrentScope().clear();
316-
getIsolationScope().clear();
317-
});
314+
// Unbind the client. Cheap, and enough for most tests that call `init()`.
315+
getCurrentScope().setClient(undefined);
316+
317+
// Reset the global, isolation, and current scopes, and the client bound to them.
318+
// `setupOnce` still does not run again, because installed integrations are tracked
319+
// outside the carrier.
320+
getMainCarrier().__SENTRY__ = undefined;
321+
322+
// Close and unbind the client. Also flushes, so it is slower.
323+
await Sentry.close();
318324
```
319325

326+
**Reset the client in every test that calls `init()`.** A second `init()` while a client is bound
327+
prints a "`Sentry.init()` was called more than once" warning. After the next major version, it will
328+
return the bound client instead of a new one, so a test without a reset gets the previous test's
329+
client and options. See `docs/repeated-init.md`.
330+
331+
To assert on that warning (or any `consoleSandbox` output), stub `originalConsoleMethods.warn` from
332+
`@sentry/core`. `consoleSandbox` calls the stored original method, so a spy on `console.warn` misses
333+
it once the console integration is set up.
334+
320335
### Grouping
321336

322337
1-2 levels of `describe` is usually enough. Deeper nesting makes tests harder to find and read.
@@ -526,6 +541,7 @@ Before you're done, verify each test against these criteria:
526541
- [ ] Description reads as a behavior specification (no "should", no "works correctly")
527542
- [ ] No dependency on other tests' execution or state
528543
- [ ] Mocks and spies are restored (via `beforeEach`)
544+
- [ ] Tests that call `init()` reset the bound client between tests
529545
- [ ] Edge cases covered: empty inputs, boundaries, error paths, null/undefined
530546
- [ ] Realistic test data (not `"foo"`, `"test"`, `123`)
531547
- [ ] No try/catch for error testing — `toThrow` / `rejects.toThrow` only

‎AGENTS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ Uses **Git Flow** (see `docs/gitflow.md`).
7676
Runtime packages (`node`, `cloudflare`, ...) re-export from
7777
`@sentry/server-utils` rather than defining their own.
7878

79+
- `Sentry.init()` follows one rule for repeated calls: the first call wins. See
80+
`docs/repeated-init.md` before you add or change an `init()`.
81+
7982
## Linting & Formatting
8083

8184
- This project uses **Oxlint** and **Oxfmt** — NOT ESLint or Prettier

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@
9090

9191
Work in this release was contributed by @Shubham-Padkonde and @tobias-schnabel. Thank you for your contributions!
9292

93+
- **feat(core)**: `Sentry.init()` now warns when it runs while a client is still active. For now, the new client still replaces the active client, but the active client is not closed, so state from both can mix. Call `Sentry.init()` once, or call `await Sentry.close()` before you call it again. `Sentry.close()` now unbinds the client it closes, so after `close()`, `getClient()` returns `undefined`, `isInitialized()` returns `false`, and a later `init()` sets up a new client. In `@sentry/cloudflare`, later requests no longer reuse the isolate's cached client once it is closed or closing. On the server, `@sentry/nextjs` and `@sentry/remix` now return the active client from a repeated `init()` call, not `undefined`.
94+
9395
## 11.2.0
9496

9597
### Important Changes
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
async function run(): Promise<void> {
5+
Sentry.init({
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
release: '1.0',
8+
transport: loggingTransport,
9+
});
10+
11+
// The root scope still holds the closed client after this.
12+
await Sentry.withIsolationScope(() => Sentry.close());
13+
14+
Sentry.init({
15+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
16+
release: '2.0',
17+
transport: loggingTransport,
18+
});
19+
20+
Sentry.captureMessage('after close in a child scope and init');
21+
}
22+
23+
run();
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
async function run(): Promise<void> {
5+
Sentry.init({
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
release: '1.0',
8+
transport: loggingTransport,
9+
});
10+
11+
await Sentry.close();
12+
13+
Sentry.init({
14+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
15+
release: '2.0',
16+
transport: loggingTransport,
17+
});
18+
19+
Sentry.captureMessage('after close and init');
20+
}
21+
22+
run();
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
transport: loggingTransport,
8+
});
9+
10+
Sentry.init({
11+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
12+
release: '2.0',
13+
transport: loggingTransport,
14+
});
15+
16+
Sentry.captureMessage('after repeated init');
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { afterAll, expect, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
3+
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
const REPEATED_INIT_WARNING = '`Sentry.init()` was called more than once';
9+
10+
test('warns on a repeated init and sends events with the new client', async () => {
11+
const runner = createRunner(__dirname, 'scenario-repeated.ts')
12+
.expect({ event: { message: 'after repeated init', release: '2.0' } })
13+
.start();
14+
15+
await runner.completed();
16+
17+
expect(runner.getLogs().join('\n')).toContain(REPEATED_INIT_WARNING);
18+
});
19+
20+
test('does not warn on init after close and sends events with the new client', async () => {
21+
const runner = createRunner(__dirname, 'scenario-close.ts')
22+
.expect({ event: { message: 'after close and init', release: '2.0' } })
23+
.start();
24+
25+
await runner.completed();
26+
27+
expect(runner.getLogs().join('\n')).not.toContain(REPEATED_INIT_WARNING);
28+
});
29+
30+
test('does not warn on init after close in a child scope', async () => {
31+
const runner = createRunner(__dirname, 'scenario-close-in-scope.ts')
32+
.expect({ event: { message: 'after close in a child scope and init', release: '2.0' } })
33+
.start();
34+
35+
await runner.completed();
36+
37+
expect(runner.getLogs().join('\n')).not.toContain(REPEATED_INIT_WARNING);
38+
});

‎docs/repeated-init.md‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Repeated `Sentry.init()` Calls
2+
3+
This document records how `Sentry.init()` should act when an app calls it
4+
more than once. Use it when you add or change an `init()` in any SDK.
5+
6+
## The rule
7+
8+
> One active client per `init()` target. The first `init()` wins. A later
9+
> `init()` changes nothing, returns the active client, and warns. To
10+
> reconfigure, call `close()` first.
11+
12+
"Active" means a client is bound to the current scope and is not closed.
13+
`Sentry.close()` closes the client and unbinds it, so a later `init()` sets
14+
up a new client. Other scopes can still hold the closed client (for example,
15+
when `close()` runs inside a request, or when code calls `client.close()`
16+
directly), so a check for "already initialized" must not use `getClient()`
17+
alone.
18+
19+
A repeated `init()` is not supported. Until the next major version, most
20+
SDKs still replace the client (see below). Do not depend on that.
21+
22+
## Why
23+
24+
When `init()` replaces a client, nothing closes the old one:
25+
26+
- Buffered logs, metrics, spans, client reports, and flush timers stay on
27+
the old client.
28+
- `setupOnce` runs only for the first client, because the list of
29+
installed integrations is global. The result mixes settings from both
30+
calls.
31+
- `initialScope` merges into the current scope on each call.
32+
33+
"First wins" never gives a user half of one config and half of another.
34+
35+
## Current behavior
36+
37+
| Entry point | Repeated call |
38+
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
39+
| `initAndBind` (browser and its wrappers, Deno), Node `_init`, Vercel Edge | Warns, then replaces the client. Returns the new client. |
40+
| Next.js server, Remix server, Hono Node | Keeps the first client and returns it. Logs in debug mode only. |
41+
| Hono Bun and Deno | Keeps the first client and returns it. Warns with its own text. |
42+
| Nuxt server | Keeps the first client and returns it. Logs that a `--import` preload is no longer needed. |
43+
| Cloudflare (default) | Keeps the first client of the isolate and returns it, unless that client is closed or closing. `cacheClient: false` makes a new client on each call, with no warning. |
44+
| Next.js edge | Warns, then replaces the client. Returns `void`. |
45+
46+
The shared warning lives in `warnIfClientIsActive()` in
47+
`packages/core/src/sdk.ts`. Core exports it as
48+
`_INTERNAL_warnIfClientIsActive` for SDKs that build their client without
49+
`initAndBind`.
50+
51+
A wrapper that expects a repeated call, such as a server bundle and a
52+
`--import` preload that both run the config, keeps its own guard and
53+
returns early. The shared warning then does not show. Guards use
54+
`getActiveClient()` (exported as `_INTERNAL_getActiveClient`), which
55+
returns the bound client only if it is not closed.
56+
57+
## TODO(v12): Plan for the next major version
58+
59+
1. Move the "first wins" guard into `initAndBind`, Node's `_init`, and
60+
Vercel Edge's `init`. Remove the guards in each wrapper.
61+
2. Switch browser to "first wins". For two apps on one page, point users
62+
to separate clients that are not bound with `init()` (see #24883).
63+
3. Make Next.js edge return the client.
64+
65+
## Tests
66+
67+
A test that calls `init()` again without a reset now prints the warning.
68+
Reset between tests with one of these:
69+
70+
- `getCurrentScope().setClient(undefined)`, or a helper that clears the
71+
carrier (`getMainCarrier().__SENTRY__ = undefined`).
72+
- `await Sentry.close()`. This also flushes, so it is slower.
73+
74+
The warning goes through `consoleSandbox`, which calls the method stored
75+
in `originalConsoleMethods`. To assert on it, replace
76+
`originalConsoleMethods.warn` with a mock. A spy on `console.warn` misses
77+
it once the console integration is set up.

‎packages/angular/test/sdk.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import * as SentryBrowser from '@sentry/browser';
2-
import { vi } from 'vitest';
2+
import { afterEach, vi } from 'vitest';
33
import { getDefaultIntegrations, init } from '../src/sdk';
44

55
describe('init', () => {
6+
afterEach(() => {
7+
SentryBrowser.getCurrentScope().setClient(undefined);
8+
});
9+
610
it('sets the Angular version (if available) in the global scope', () => {
711
const setContextSpy = vi.spyOn(SentryBrowser, 'setContext');
812

‎packages/angular/test/tracing.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ElementRef } from '@angular/core';
22
import type { ActivatedRouteSnapshot } from '@angular/router';
3-
import { getMainCarrier, SentrySpan, spanToJSON, startSpan } from '@sentry/core';
4-
import { describe, it } from 'vitest';
3+
import { getCurrentScope, getMainCarrier, SentrySpan, spanToJSON, startSpan } from '@sentry/core';
4+
import { beforeEach, describe, it } from 'vitest';
55
import { browserTracingIntegration, init, TraceClass, TraceDirective } from '../src/index';
66
import { _updateSpanAttributesForParametrizedUrl, getParameterizedRouteFromSnapshot } from '../src/tracing';
77
import {
@@ -74,6 +74,10 @@ describe('Angular Tracing', () => {
7474
});
7575

7676
describe('TraceService', () => {
77+
beforeEach(() => {
78+
getCurrentScope().setClient(undefined);
79+
});
80+
7781
it('change the span name to route name if the the source is `url`', async () => {
7882
init({ integrations: [browserTracingIntegration()] });
7983

0 commit comments

Comments
 (0)