Skip to content

Commit a362f65

Browse files
JPeer264cursoragent
andcommitted
feat(cloudflare): Add 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. Enabled by default, this caches one client per isolate. The first initialization wins for the isolate's lifetime: a later init with different options reuses that client, and a new deployment always starts fresh isolates, so clients are always built from the current version's options. 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: the new `afterEnvelope` hook on the core client drains the transport buffer as soon as an envelope has been accepted, and logs and metrics drain on a debounced hook so they are batched rather than sent one at a time. Spans that end after the invocation's flush point are delivered through core's `flushTraceSpans` hook, which flushes only that trace's bucket from the span streaming buffer. 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. A shared client also shares integration state, so dedupe works across invocations: the same error raised by two separate requests is reported only once. Uncached behavior is unchanged; pass `cacheClient: false` to restore it. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0a80fe6 commit a362f65

29 files changed

Lines changed: 2089 additions & 56 deletions
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
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+
/**
11+
* Sync KV and SQL work against the DO's own storage, which the SDK instruments into `db` spans.
12+
* Used to check that those spans still reach the transport from inside a Durable Object, where a
13+
* cached client never hits an invocation-boundary flush and has to rely on the eager drain.
14+
*/
15+
function runStorageOps(ctx: DurableObjectState): { listSize: number; rows: number } {
16+
ctx.storage.kv.put('cache-key', { hello: 'sync' });
17+
ctx.storage.kv.get('cache-key');
18+
const entries = [...ctx.storage.kv.list()];
19+
ctx.storage.kv.delete('cache-key');
20+
21+
ctx.storage.sql.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)');
22+
ctx.storage.sql.exec('INSERT INTO users (name) VALUES (?)', 'Alice');
23+
const rows = ctx.storage.sql.exec('SELECT * FROM users').toArray();
24+
25+
return { listSize: entries.length, rows: rows.length };
26+
}
27+
28+
function startDetachedWork(message: string): string {
29+
void (async () => {
30+
await new Promise(r => setTimeout(r, 3000));
31+
await Sentry.startSpan({ name: 'do.detached-task', op: 'task' }, async () => {
32+
Sentry.logger.info(`Detached log: ${message}`);
33+
Sentry.metrics.count('do.detached', 1);
34+
Sentry.captureException(new Error(message));
35+
});
36+
})();
37+
return `Detached work started: ${message}`;
38+
}
39+
40+
// DO with cacheClient: true (the default) — detached work events SHOULD be captured
41+
class CacheDurableObjectBase extends DurableObject<Env> {
42+
async echo(n: number): Promise<number> {
43+
return n;
44+
}
45+
46+
async handlerError(instanceId: string): Promise<void> {
47+
throw new Error(`Cache DO handler error from ${instanceId}`);
48+
}
49+
50+
async dedupe(): Promise<string> {
51+
Sentry.captureException(new Error('Same error'));
52+
return 'dedupe test';
53+
}
54+
55+
async scopeCheck(seed: boolean): Promise<string> {
56+
if (seed) {
57+
Sentry.setTag('seeded_tag', 'from-seeding-call');
58+
Sentry.setUser({ id: 'user-from-seeding-call' });
59+
}
60+
Sentry.captureException(new Error(seed ? 'Cache scope seed' : 'Cache scope probe'));
61+
return 'ok';
62+
}
63+
64+
async storage(): Promise<string> {
65+
const { listSize, rows } = runStorageOps(this.ctx);
66+
return `cache storage ${listSize}/${rows}`;
67+
}
68+
69+
async fetch(request: Request): Promise<Response> {
70+
const url = new URL(request.url);
71+
if (url.pathname === '/detached') {
72+
return new Response(startDetachedWork(`Detached work from cache DO ${url.searchParams.get('id')}`));
73+
}
74+
if (url.pathname === '/streaming') {
75+
const stream = new ReadableStream({
76+
start(controller) {
77+
controller.enqueue(new TextEncoder().encode('chunk1'));
78+
controller.enqueue(new TextEncoder().encode('chunk2'));
79+
controller.close();
80+
},
81+
});
82+
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } });
83+
}
84+
return new Response('Cache DO');
85+
}
86+
}
87+
88+
// DO with cacheClient: false — detached work events should NOT be captured
89+
class NoCacheDurableObjectBase extends DurableObject<Env> {
90+
async handlerError(instanceId: string): Promise<void> {
91+
throw new Error(`No-cache DO handler error from ${instanceId}`);
92+
}
93+
94+
async dedupe(): Promise<string> {
95+
Sentry.captureException(new Error('Same error'));
96+
return 'dedupe test';
97+
}
98+
99+
async storage(): Promise<string> {
100+
const { listSize, rows } = runStorageOps(this.ctx);
101+
return `no-cache storage ${listSize}/${rows}`;
102+
}
103+
104+
async fetch(request: Request): Promise<Response> {
105+
const url = new URL(request.url);
106+
if (url.pathname === '/detached') {
107+
return new Response(startDetachedWork(`Detached work from no-cache DO ${url.searchParams.get('id')}`));
108+
}
109+
return new Response('No-cache DO');
110+
}
111+
}
112+
113+
export const CacheDurableObject = Sentry.instrumentDurableObjectWithSentry(
114+
(env: Env) => ({
115+
dsn: env.SENTRY_DSN,
116+
tracesSampleRate: 1,
117+
enableLogs: true,
118+
enableRpcTracePropagation: true,
119+
}),
120+
CacheDurableObjectBase,
121+
);
122+
123+
export const NoCacheDurableObject = Sentry.instrumentDurableObjectWithSentry(
124+
(env: Env) => ({
125+
dsn: env.SENTRY_DSN,
126+
tracesSampleRate: 1,
127+
cacheClient: false,
128+
enableRpcTracePropagation: true,
129+
}),
130+
NoCacheDurableObjectBase,
131+
);
132+
133+
export default Sentry.withSentry(
134+
(env: Env) => ({
135+
dsn: env.SENTRY_DSN,
136+
tracesSampleRate: 1,
137+
enableLogs: true,
138+
enableRpcTracePropagation: true,
139+
}),
140+
{
141+
async fetch(request, env, ctx) {
142+
const url = new URL(request.url);
143+
const instanceId = url.searchParams.get('id') || 'default';
144+
145+
// Work that finishes AFTER the response: a post-response span tree plus a
146+
// log, metric and error, all registered via waitUntil. This is the worker-side
147+
// half of the #22545 lifecycle (the DO-side half is /detached).
148+
if (url.pathname === '/post-response') {
149+
ctx.waitUntil(
150+
Sentry.startSpan({ name: 'checkout.post-response', op: 'task' }, async () => {
151+
Sentry.logger.info('checkout post-response log');
152+
Sentry.metrics.count('checkout.processed', 1);
153+
await new Promise(r => setTimeout(r, 50));
154+
await Sentry.startSpan({ name: 'checkout.notify-webhook', op: 'http.client' }, async () => {
155+
await new Promise(r => setTimeout(r, 25));
156+
Sentry.captureException(new Error('Webhook delivery failed'));
157+
});
158+
}),
159+
);
160+
return new Response('checkout accepted');
161+
}
162+
163+
// Fan a single request out into N sequential DO RPC calls — every RPC span must
164+
// land in this request's trace when RPC trace propagation is on.
165+
if (url.pathname === '/burst') {
166+
const n = Math.min(Number(url.searchParams.get('n')) || 1, 20);
167+
const stub = env.CACHE_DO.get(
168+
env.CACHE_DO.idFromName(`burst-${instanceId}`),
169+
) as DurableObjectStub<CacheDurableObjectBase>;
170+
171+
let sum = 0;
172+
for (let i = 0; i < n; i++) {
173+
sum += (await stub.echo(i)) as number;
174+
}
175+
return Response.json({ calls: n, sum });
176+
}
177+
178+
// Cache DO RPC calls
179+
if (url.pathname === '/cache/handler-error') {
180+
const stub = env.CACHE_DO.get(
181+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
182+
) as DurableObjectStub<CacheDurableObjectBase>;
183+
await stub.handlerError(instanceId);
184+
}
185+
186+
if (url.pathname === '/cache/dedupe') {
187+
const stub = env.CACHE_DO.get(
188+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
189+
) as DurableObjectStub<CacheDurableObjectBase>;
190+
const result = await stub.dedupe();
191+
return new Response(String(result));
192+
}
193+
194+
if (url.pathname === '/cache/scope') {
195+
const stub = env.CACHE_DO.get(
196+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
197+
) as DurableObjectStub<CacheDurableObjectBase>;
198+
return new Response(await stub.scopeCheck(url.searchParams.get('seed') === '1'));
199+
}
200+
201+
// Cache DO fetch calls — detached work goes through fetch (matching the #22545 repro),
202+
// since the DO fetch handler always initializes the DO's own client
203+
if (url.pathname === '/cache/detached') {
204+
const stub = env.CACHE_DO.get(
205+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
206+
) as DurableObjectStub<CacheDurableObjectBase>;
207+
return stub.fetch(new Request(`http://do/detached?id=${instanceId}`));
208+
}
209+
210+
if (url.pathname === '/cache/storage') {
211+
const stub = env.CACHE_DO.get(
212+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
213+
) as DurableObjectStub<CacheDurableObjectBase>;
214+
return new Response(await stub.storage());
215+
}
216+
217+
if (url.pathname === '/cache/streaming') {
218+
const stub = env.CACHE_DO.get(
219+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
220+
) as DurableObjectStub<CacheDurableObjectBase>;
221+
return stub.fetch(new Request('http://do/streaming'));
222+
}
223+
224+
// No-cache DO calls
225+
if (url.pathname === '/no-cache/handler-error') {
226+
const stub = env.NO_CACHE_DO.get(
227+
env.NO_CACHE_DO.idFromName(`no-cache-do-${instanceId}`),
228+
) as DurableObjectStub<NoCacheDurableObjectBase>;
229+
await stub.handlerError(instanceId);
230+
}
231+
232+
if (url.pathname === '/no-cache/dedupe') {
233+
const stub = env.NO_CACHE_DO.get(
234+
env.NO_CACHE_DO.idFromName(`no-cache-do-${instanceId}`),
235+
) as DurableObjectStub<NoCacheDurableObjectBase>;
236+
const result = await stub.dedupe();
237+
return new Response(String(result));
238+
}
239+
240+
if (url.pathname === '/no-cache/storage') {
241+
const stub = env.NO_CACHE_DO.get(
242+
env.NO_CACHE_DO.idFromName(`no-cache-do-${instanceId}`),
243+
) as DurableObjectStub<NoCacheDurableObjectBase>;
244+
return new Response(await stub.storage());
245+
}
246+
247+
if (url.pathname === '/no-cache/detached') {
248+
const stub = env.NO_CACHE_DO.get(
249+
env.NO_CACHE_DO.idFromName(`no-cache-do-${instanceId}`),
250+
) as DurableObjectStub<NoCacheDurableObjectBase>;
251+
return stub.fetch(new Request(`http://do/detached?id=${instanceId}`));
252+
}
253+
254+
return new Response('Hello World!');
255+
},
256+
} satisfies ExportedHandler<Env>,
257+
);

0 commit comments

Comments
 (0)