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