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