From 0a80fe67ec85f1a177f3414094389a053ab19bba Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 7 Aug 2026 10:36:24 +0200 Subject: [PATCH] feat(core): Add `afterEnvelope` and `flushTraceSpans` client hooks `afterEnvelope` fires after the transport has accepted an envelope. Runtimes that flush eagerly (e.g. the Cloudflare SDK's cached client) use it to drain the transport buffer at capture time instead of waiting for an invocation boundary. `flushTraceSpans` lets a runtime flush a single trace's span bucket from the span streaming buffer ahead of the regular flush points, without draining the whole buffer mid-invocation and fragmenting concurrent traces. Both are no-ops for runtimes that never emit them. --- packages/core/src/client.ts | 28 +++++++++++++++- .../core/src/integrations/spanStreaming.ts | 6 ++++ .../test/integrations/spanStreaming.test.ts | 19 +++++++++++ packages/core/test/lib/client.test.ts | 33 +++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 778d03b7541c..6878dafbc038 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -728,6 +728,12 @@ export abstract class Client { */ public on(hook: 'beforeEnvelope', callback: (envelope: Envelope) => void): () => void; + /** + * Register a callback for after an envelope has been accepted by the transport. + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'afterEnvelope', callback: (envelope: Envelope) => void): () => void; + /** * Register a callback that runs when stack frame metadata should be applied to an event. * @returns {() => void} A function that, when executed, removes the registered callback. @@ -881,6 +887,14 @@ export abstract class Client { */ public on(hook: 'flush', callback: () => void): () => void; + /** + * A hook that is called when spans of a single trace should be flushed eagerly, + * ahead of the trace's regular flush point. Only runtimes with a span streaming + * buffer (e.g. the Cloudflare SDK) listen to this hook. + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'flushTraceSpans', callback: (traceId: string) => void): () => void; + /** * A hook that is called when the client is closing * @returns {() => void} A function that, when executed, removes the registered callback. @@ -1051,6 +1065,11 @@ export abstract class Client { */ public emit(hook: 'beforeEnvelope', envelope: Envelope): void; + /** + * Fire a hook event after an envelope has been accepted by the transport. + */ + public emit(hook: 'afterEnvelope', envelope: Envelope): void; + /** * Fire a hook indicating that stack frame metadata should be applied to the event passed to the hook. */ @@ -1175,6 +1194,11 @@ export abstract class Client { */ public emit(hook: 'flush'): void; + /** + * Fire a hook event indicating that spans of a single trace should be flushed eagerly. + */ + public emit(hook: 'flushTraceSpans', traceId: string): void; + /** * Emit a hook event for client close */ @@ -1258,7 +1282,9 @@ export abstract class Client { if (this._isEnabled() && this._transport) { try { - return await this._transport.send(envelope); + const result = await this._transport.send(envelope); + this.emit('afterEnvelope', envelope); + return result; } catch (reason) { DEBUG_BUILD && debug.error('Error while sending envelope:', reason); return {}; diff --git a/packages/core/src/integrations/spanStreaming.ts b/packages/core/src/integrations/spanStreaming.ts index d325906b6b1e..d44ac947bad4 100644 --- a/packages/core/src/integrations/spanStreaming.ts +++ b/packages/core/src/integrations/spanStreaming.ts @@ -27,6 +27,12 @@ export const spanStreamingIntegration = defineIntegration(() => { } buffer.add(captureSpan(span, client)); }); + + // Lets runtimes flush a single trace eagerly (e.g. the Cloudflare SDK draining + // a trace the moment its segment ends), without exposing the buffer itself. + client.on('flushTraceSpans', traceId => { + buffer.flush(traceId); + }); }, }; }) satisfies IntegrationFn; diff --git a/packages/core/test/integrations/spanStreaming.test.ts b/packages/core/test/integrations/spanStreaming.test.ts index 3219fb4e14d4..610aeb2be8c1 100644 --- a/packages/core/test/integrations/spanStreaming.test.ts +++ b/packages/core/test/integrations/spanStreaming.test.ts @@ -133,4 +133,23 @@ describe('spanStreamingIntegration (core)', () => { expect(mockSpanBufferInstance.add).not.toHaveBeenCalled(); }); + + it('flushes a single trace when the flushTraceSpans hook is emitted', () => { + const client = new TestClient({ + ...getDefaultTestClientOptions(), + dsn: 'https://username@domain/123', + integrations: [spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, + }); + + SentryCore.setCurrentClient(client); + client.init(); + + client.emit('flushTraceSpans', 'trace-1'); + + expect(mockSpanBufferInstance.flush).toHaveBeenCalledTimes(1); + expect(mockSpanBufferInstance.flush).toHaveBeenCalledWith('trace-1'); + expect(mockSpanBufferInstance.drain).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index fc34bb12f045..2784062a809b 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -3073,6 +3073,39 @@ describe('Client', () => { client.emit('beforeEnvelope', mockEnvelope); }); + it('calls an afterEnvelope hook after the transport send resolves', async () => { + let resolveSend: (() => void) | undefined; + const sendPromise = new Promise(resolve => { + resolveSend = resolve; + }); + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + transport: () => ({ + send: vi.fn().mockReturnValue(sendPromise), + flush: vi.fn().mockResolvedValue(true), + }), + }), + ); + const mockEnvelope = [ + { + event_id: '12345', + }, + [], + ] as Envelope; + const callback = vi.fn(); + client.on('afterEnvelope', callback); + + const result = client.sendEnvelope(mockEnvelope); + expect(callback).not.toHaveBeenCalled(); + + resolveSend?.(); + await result; + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(mockEnvelope); + }); + it('returns a cleanup function that, when executed, unregisters a hook', async () => { vi.useFakeTimers(); expect.assertions(8);