|
| 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