Skip to content

Commit b624208

Browse files
JPeer264cursoragent
andcommitted
feat(cloudflare): Add experimental.cacheClient to reuse the client across invocations
Building and disposing a client per invocation costs real time on every request, and in a Durable Object it also loses data: there is no `waitUntil` boundary that dependably extends execution, so anything captured after the handler returned went to a client that had already been disposed (#22545). Opting in caches one client per isolate, keyed by DSN plus a fingerprint of the user-supplied options so that differing configurations never share a client. A cached client is flushed but not disposed at an invocation boundary, and it is re-bound to the current scope on every invocation — otherwise `initialScope` would apply only to an isolate's first invocation, and a client disposed by a competing init would keep being handed out. A cached client whose transport is gone is evicted rather than returned. Because a reused client never reaches an end-of-invocation flush, delivery is eager: envelopes drain as they are sent, and logs and metrics drain on a debounced hook so they are batched rather than sent one at a time. The per-invocation flush lock and span tracking are skipped, since binding a client that outlives the invocation to one invocation's lock would make later flushes wait on that invocation's work forever. Dedupe is scoped to a single invocation in this mode, so a shared client does not drop a second invocation's copy of a recurring error. Uncached behavior is unchanged; the option defaults to off. Co-authored-by: Cursor <[email protected]>
1 parent 71f74e8 commit b624208

13 files changed

Lines changed: 1278 additions & 42 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@
66

77
Work in this release was contributed by @psh4607, @trinitiwowka, @nehaprasad-dev, and @JealousGx. Thank you for your contributions!
88

9+
- feat(cloudflare): Add `experimental.cacheClient` to reuse one client across invocations ([#TODO](https://github.com/getsentry/sentry-javascript/pull/TODO)). Opting in reuses a single client per isolate instead of building and disposing one per invocation, which removes that per-invocation cost and makes Durable Object telemetry reliable: a Durable Object has no `waitUntil` boundary that dependably extends execution, so with a per-invocation client anything captured after the handler returned was lost. Since a reused client never reaches an end-of-invocation flush, captured data is delivered eagerly instead.
10+
11+
```js
12+
Sentry.init({
13+
dsn: env.SENTRY_DSN,
14+
experimental: { cacheClient: true },
15+
});
16+
```
17+
918
- fix(cloudflare): Stop Durable Object scope data leaking between invocations ([#TODO](https://github.com/getsentry/sentry-javascript/pull/TODO)). Instrumented Durable Object methods forked only the current scope, while `setUser`/`setTag` write to the isolation scope — which a Durable Object keeps across invocations. A user set while handling one invocation could therefore be attached to later invocations' events in the same isolate.
1019

1120
- feat(deno)!: Rename several default integrations to match the other SDKs ([#22404](https://github.com/getsentry/sentry-javascript/pull/22404)). The `deno*Integration` exports are kept as deprecated aliases. If you were relying on the names (for example, to disable them), then note that these have changed:
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { DurableObject } from 'cloudflare:workers';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
CACHE_DO: DurableObjectNamespace;
7+
NO_CACHE_DO: DurableObjectNamespace;
8+
}
9+
10+
function startDetachedWork(message: string): string {
11+
void (async () => {
12+
await new Promise(r => setTimeout(r, 3000));
13+
Sentry.logger.info(`Detached log: ${message}`);
14+
Sentry.captureException(new Error(message));
15+
})();
16+
return `Detached work started: ${message}`;
17+
}
18+
19+
// DO with cacheClient: true — detached work events SHOULD be captured
20+
class CacheDurableObjectBase extends DurableObject<Env> {
21+
async handlerError(instanceId: string): Promise<void> {
22+
throw new Error(`Cache DO handler error from ${instanceId}`);
23+
}
24+
25+
async dedupe(): Promise<string> {
26+
Sentry.captureException(new Error('Same error'));
27+
return 'dedupe test';
28+
}
29+
30+
async scopeCheck(seed: boolean): Promise<string> {
31+
if (seed) {
32+
Sentry.setTag('seeded_tag', 'from-seeding-call');
33+
Sentry.setUser({ id: 'user-from-seeding-call' });
34+
}
35+
Sentry.captureException(new Error(seed ? 'Cache scope seed' : 'Cache scope probe'));
36+
return 'ok';
37+
}
38+
39+
async fetch(request: Request): Promise<Response> {
40+
const url = new URL(request.url);
41+
if (url.pathname === '/detached') {
42+
return new Response(startDetachedWork(`Detached work from cache DO ${url.searchParams.get('id')}`));
43+
}
44+
if (url.pathname === '/streaming') {
45+
const stream = new ReadableStream({
46+
start(controller) {
47+
controller.enqueue(new TextEncoder().encode('chunk1'));
48+
controller.enqueue(new TextEncoder().encode('chunk2'));
49+
controller.close();
50+
},
51+
});
52+
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } });
53+
}
54+
return new Response('Cache DO');
55+
}
56+
}
57+
58+
// DO with cacheClient: false (default) — detached work events should NOT be captured
59+
class NoCacheDurableObjectBase extends DurableObject<Env> {
60+
async handlerError(instanceId: string): Promise<void> {
61+
throw new Error(`No-cache DO handler error from ${instanceId}`);
62+
}
63+
64+
async scopeCheck(seed: boolean): Promise<string> {
65+
if (seed) {
66+
Sentry.setTag('seeded_tag', 'from-seeding-call');
67+
Sentry.setUser({ id: 'user-from-seeding-call' });
68+
}
69+
Sentry.captureException(new Error(seed ? 'No-cache scope seed' : 'No-cache scope probe'));
70+
return 'ok';
71+
}
72+
73+
async fetch(request: Request): Promise<Response> {
74+
const url = new URL(request.url);
75+
if (url.pathname === '/detached') {
76+
return new Response(startDetachedWork(`Detached work from no-cache DO ${url.searchParams.get('id')}`));
77+
}
78+
return new Response('No-cache DO');
79+
}
80+
}
81+
82+
export const CacheDurableObject = Sentry.instrumentDurableObjectWithSentry(
83+
(env: Env) => ({
84+
dsn: env.SENTRY_DSN,
85+
tracesSampleRate: 1,
86+
enableLogs: true,
87+
experimental: { cacheClient: true },
88+
enableRpcTracePropagation: true,
89+
}),
90+
CacheDurableObjectBase,
91+
);
92+
93+
export const NoCacheDurableObject = Sentry.instrumentDurableObjectWithSentry(
94+
(env: Env) => ({
95+
dsn: env.SENTRY_DSN,
96+
tracesSampleRate: 1,
97+
experimental: { cacheClient: false },
98+
enableRpcTracePropagation: true,
99+
}),
100+
NoCacheDurableObjectBase,
101+
);
102+
103+
export default Sentry.withSentry(
104+
(env: Env) => ({
105+
dsn: env.SENTRY_DSN,
106+
tracesSampleRate: 1,
107+
enableLogs: true,
108+
experimental: { cacheClient: true },
109+
enableRpcTracePropagation: true,
110+
}),
111+
{
112+
async fetch(request, env) {
113+
const url = new URL(request.url);
114+
const instanceId = url.searchParams.get('id') || 'default';
115+
116+
// Cache DO RPC calls
117+
if (url.pathname === '/cache/handler-error') {
118+
const stub = env.CACHE_DO.get(
119+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
120+
) as DurableObjectStub<CacheDurableObjectBase>;
121+
await stub.handlerError(instanceId);
122+
}
123+
124+
if (url.pathname === '/cache/dedupe') {
125+
const stub = env.CACHE_DO.get(
126+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
127+
) as DurableObjectStub<CacheDurableObjectBase>;
128+
const result = await stub.dedupe();
129+
return new Response(String(result));
130+
}
131+
132+
if (url.pathname === '/cache/scope') {
133+
const stub = env.CACHE_DO.get(
134+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
135+
) as DurableObjectStub<CacheDurableObjectBase>;
136+
return new Response(await stub.scopeCheck(url.searchParams.get('seed') === '1'));
137+
}
138+
139+
// Cache DO fetch calls — detached work goes through fetch (matching the #22545 repro),
140+
// since the DO fetch handler always initializes the DO's own client
141+
if (url.pathname === '/cache/detached') {
142+
const stub = env.CACHE_DO.get(
143+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
144+
) as DurableObjectStub<CacheDurableObjectBase>;
145+
return stub.fetch(new Request(`http://do/detached?id=${instanceId}`));
146+
}
147+
148+
if (url.pathname === '/cache/streaming') {
149+
const stub = env.CACHE_DO.get(
150+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
151+
) as DurableObjectStub<CacheDurableObjectBase>;
152+
return stub.fetch(new Request('http://do/streaming'));
153+
}
154+
155+
if (url.pathname === '/no-cache/scope') {
156+
const stub = env.NO_CACHE_DO.get(
157+
env.NO_CACHE_DO.idFromName(`no-cache-do-${instanceId}`),
158+
) as DurableObjectStub<NoCacheDurableObjectBase>;
159+
return new Response(await stub.scopeCheck(url.searchParams.get('seed') === '1'));
160+
}
161+
162+
// No-cache DO calls
163+
if (url.pathname === '/no-cache/handler-error') {
164+
const stub = env.NO_CACHE_DO.get(
165+
env.NO_CACHE_DO.idFromName(`no-cache-do-${instanceId}`),
166+
) as DurableObjectStub<NoCacheDurableObjectBase>;
167+
await stub.handlerError(instanceId);
168+
}
169+
170+
if (url.pathname === '/no-cache/detached') {
171+
const stub = env.NO_CACHE_DO.get(
172+
env.NO_CACHE_DO.idFromName(`no-cache-do-${instanceId}`),
173+
) as DurableObjectStub<NoCacheDurableObjectBase>;
174+
return stub.fetch(new Request(`http://do/detached?id=${instanceId}`));
175+
}
176+
177+
return new Response('Hello World!');
178+
},
179+
} satisfies ExportedHandler<Env>,
180+
);
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import type { Envelope, Event } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../runner';
4+
5+
type Mechanism = { type: string; handled: boolean };
6+
7+
/**
8+
* Matches an error event by exception value and capture mechanism.
9+
*
10+
* Callback-style (instead of exact `eventEnvelope` matching) because Durable Object
11+
* RPC events carry no `request` and their trace context varies with propagation —
12+
* only the exception payload is stable. Non-matching envelopes (worker-side duplicate
13+
* captures, transactions) are dropped by the runner's unordered mode.
14+
*/
15+
function errorEventExpectation(value: string, mechanism: Mechanism) {
16+
return (envelope: Envelope) => {
17+
const event = envelope[1]?.[0]?.[1] as Event;
18+
expect(event).toEqual(
19+
expect.objectContaining({
20+
level: 'error',
21+
exception: {
22+
values: [
23+
expect.objectContaining({
24+
type: 'Error',
25+
value,
26+
stacktrace: { frames: expect.any(Array) },
27+
mechanism,
28+
}),
29+
],
30+
},
31+
}),
32+
);
33+
};
34+
}
35+
36+
const DO_MECHANISM: Mechanism = { type: 'auto.faas.cloudflare.durable_object', handled: false };
37+
// Direct `captureException` calls (not routed through a wrapped handler) always get this mechanism
38+
const CAPTURE_MECHANISM: Mechanism = { type: 'generic', handled: true };
39+
40+
it('cacheClient: false - DO handler error is captured', async ({ signal }) => {
41+
const runner = createRunner(__dirname)
42+
.expect(errorEventExpectation('No-cache DO handler error from instance-1', DO_MECHANISM))
43+
.expect(errorEventExpectation('No-cache DO handler error from instance-2', DO_MECHANISM))
44+
.unordered()
45+
.start(signal);
46+
47+
await runner.makeRequest('get', '/no-cache/handler-error?id=instance-1', { expectError: true });
48+
await runner.makeRequest('get', '/no-cache/handler-error?id=instance-2', { expectError: true });
49+
await runner.completed();
50+
});
51+
52+
it('cacheClient: true - DO handler error is captured', async ({ signal }) => {
53+
const runner = createRunner(__dirname)
54+
.expect(errorEventExpectation('Cache DO handler error from instance-1', DO_MECHANISM))
55+
.expect(errorEventExpectation('Cache DO handler error from instance-2', DO_MECHANISM))
56+
.unordered()
57+
.start(signal);
58+
59+
await runner.makeRequest('get', '/cache/handler-error?id=instance-1', { expectError: true });
60+
await runner.makeRequest('get', '/cache/handler-error?id=instance-2', { expectError: true });
61+
await runner.completed();
62+
});
63+
64+
it('cacheClient: true - detached work events ARE captured', async ({ signal }) => {
65+
const runner = createRunner(__dirname)
66+
.expect(errorEventExpectation('Detached work from cache DO instance-1', CAPTURE_MECHANISM))
67+
.expect(errorEventExpectation('Detached work from cache DO instance-2', CAPTURE_MECHANISM))
68+
// Logs batch client-side and the idle drain timer is disabled for this runtime, so a
69+
// log only ever becomes an envelope if the cached client drains its log buffer on
70+
// capture. Without that, detached logs are silently dropped while errors still arrive.
71+
.expect((envelope: Envelope) => {
72+
const payload = envelope[1]?.[0]?.[1] as { items?: Array<{ body?: string }> };
73+
expect(payload.items?.some(log => log.body?.startsWith('Detached log: Detached work from cache DO'))).toBe(true);
74+
})
75+
.unordered()
76+
.start(signal);
77+
78+
await runner.makeRequest('get', '/cache/detached?id=instance-1');
79+
await runner.makeRequest('get', '/cache/detached?id=instance-2');
80+
await runner.completed();
81+
});
82+
83+
it('cacheClient: false - repro #22545: detached work events are silently dropped', async ({ signal }) => {
84+
const runner = createRunner(__dirname).ignore('transaction').start(signal);
85+
86+
// Make the request that spawns detached work
87+
await runner.makeRequest('get', '/no-cache/detached?id=repro-1');
88+
89+
// With cacheClient: false, the client is disposed after the handler returns,
90+
// so the detached work's captureException (3s later) is silently dropped.
91+
// We verify by waiting for the event with a timeout — if it doesn't arrive,
92+
// the event was silently dropped as expected.
93+
const result = await Promise.race([
94+
runner.makeRequestAndWaitForEnvelope('get', '/no-cache/detached?id=repro-2', () => {
95+
throw new Error('Received an event that should have been dropped with cacheClient: false');
96+
}),
97+
// Timeout: resolve with 'timeout' if no event arrives within 5s
98+
new Promise<string>(resolve => setTimeout(() => resolve('timeout'), 5000)),
99+
]);
100+
101+
// The event should NOT have been received (timeout should win the race)
102+
expect(result).toBe('timeout');
103+
});
104+
105+
it('cacheClient: true - dedupe reports the same error in each separate invocation', async ({ signal }) => {
106+
// A shared client shares its dedupe state too. RPC methods are wrapped with `withScope`,
107+
// which forks the current scope but leaves the isolation scope shared, so dedupe cannot
108+
// key on the isolation scope alone: three calls into the same Durable Object would report
109+
// only the first error and silently drop the rest, unlike an uncached client.
110+
const runner = createRunner(__dirname).ignore('transaction').start(signal);
111+
112+
for (let i = 0; i < 3; i++) {
113+
await runner.makeRequestAndWaitForEnvelope(
114+
'get',
115+
'/cache/dedupe?id=dedupe-shared',
116+
errorEventExpectation('Same error', CAPTURE_MECHANISM),
117+
);
118+
}
119+
});
120+
121+
/**
122+
* Seeds `setTag`/`setUser` in one Durable Object invocation, then asserts a later invocation of the
123+
* same instance reports neither. Both write to the isolation scope, which a Durable Object keeps
124+
* across invocations, so wrapped methods have to fork it — and disposing the client at the
125+
* invocation boundary does not reset it, which is why this has to hold with caching either way.
126+
*/
127+
async function expectNoScopeLeak(signal: AbortSignal, route: string, label: string): Promise<void> {
128+
const runner = createRunner(__dirname).ignore('transaction').start(signal);
129+
130+
await runner.makeRequestAndWaitForEnvelope('get', `${route}?id=scope-shared&seed=1`, (envelope: Envelope) => {
131+
const event = envelope[1]?.[0]?.[1] as Event;
132+
expect(event.exception?.values?.[0]?.value).toBe(`${label} scope seed`);
133+
expect(event.tags).toEqual(expect.objectContaining({ seeded_tag: 'from-seeding-call' }));
134+
expect(event.user).toEqual({ id: 'user-from-seeding-call' });
135+
});
136+
137+
await runner.makeRequestAndWaitForEnvelope('get', `${route}?id=scope-shared&seed=0`, (envelope: Envelope) => {
138+
const event = envelope[1]?.[0]?.[1] as Event;
139+
expect(event.exception?.values?.[0]?.value).toBe(`${label} scope probe`);
140+
expect(event.tags?.seeded_tag).toBeUndefined();
141+
expect(event.user).toBeUndefined();
142+
});
143+
}
144+
145+
it('cacheClient: true - scope data does not leak into the next invocation', async ({ signal }) => {
146+
await expectNoScopeLeak(signal, '/cache/scope', 'Cache');
147+
});
148+
149+
it('cacheClient: false - scope data does not leak into the next invocation', async ({ signal }) => {
150+
await expectNoScopeLeak(signal, '/no-cache/scope', 'No-cache');
151+
});
152+
153+
it('cacheClient: true - streaming response works with shared client', async ({ signal }) => {
154+
const transactionExpectation = (envelope: Envelope) => {
155+
const tx = envelope[1]?.[0]?.[1] as Event;
156+
expect(tx.transaction).toBe('GET /streaming');
157+
};
158+
159+
const runner = createRunner(__dirname)
160+
.expect(transactionExpectation)
161+
.expect(transactionExpectation)
162+
.unordered()
163+
.start(signal);
164+
165+
const text1 = await runner.makeRequest<string>('get', '/cache/streaming');
166+
expect(text1).toBe('chunk1chunk2');
167+
168+
const text2 = await runner.makeRequest<string>('get', '/cache/streaming');
169+
expect(text2).toBe('chunk1chunk2');
170+
171+
await runner.completed();
172+
});
173+
174+
it('cacheClient: true - multiple DO instances share the same client', async ({ signal }) => {
175+
const runner = createRunner(__dirname)
176+
.expect(errorEventExpectation('Cache DO handler error from instance-1', DO_MECHANISM))
177+
.expect(errorEventExpectation('Cache DO handler error from instance-2', DO_MECHANISM))
178+
.unordered()
179+
.start(signal);
180+
181+
// Two different DO instances — both should capture errors
182+
await runner.makeRequest('get', '/cache/handler-error?id=instance-1', { expectError: true });
183+
await runner.makeRequest('get', '/cache/handler-error?id=instance-2', { expectError: true });
184+
await runner.completed();
185+
});

0 commit comments

Comments
 (0)