Skip to content

Commit 991aade

Browse files
committed
fix(node): Flush buffered streamed spans on process exit
A process that exits on its own lost every span still in the span buffer, since its flush timers are unref'd. Flush it on `beforeExit`, like logs and metrics.
1 parent b151d07 commit 991aade

2 files changed

Lines changed: 45 additions & 4 deletions

File tree

‎packages/node/src/sdk/client.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
4040
private _clientReportOnExitFlushListener: (() => void) | undefined;
4141
private _logOnExitFlushListener: (() => void) | undefined;
4242
private _metricsOnExitFlushListener: (() => void) | undefined;
43+
private _spanOnExitFlushListener: (() => void) | undefined;
4344

4445
public constructor(options: NodeClientOptions) {
4546
const serverName =
@@ -69,6 +70,12 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
6970
_INTERNAL_flushMetricsBuffer(this);
7071
};
7172

73+
// The span buffer drains on the client's `flush` hook. Its timers are unref'd, so without this a
74+
// process that exits on its own drops every span still waiting in the buffer.
75+
this._spanOnExitFlushListener = () => {
76+
this.emit('flush');
77+
};
78+
7279
if (serverName) {
7380
this.on('beforeCaptureLog', log => {
7481
log.attributes = {
@@ -80,6 +87,7 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
8087

8188
process.on('beforeExit', this._logOnExitFlushListener);
8289
process.on('beforeExit', this._metricsOnExitFlushListener);
90+
process.on('beforeExit', this._spanOnExitFlushListener);
8391

8492
// Enable deferred segment-span transaction capture here, in the constructor, rather than in
8593
// `initOtel`. Every client runs its constructor exactly once, whereas `initOtel` only runs on
@@ -169,6 +177,10 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
169177
process.off('beforeExit', this._metricsOnExitFlushListener);
170178
}
171179

180+
if (this._spanOnExitFlushListener) {
181+
process.off('beforeExit', this._spanOnExitFlushListener);
182+
}
183+
172184
const allEventsSent = await super.close(timeout);
173185
if (this.traceProvider) {
174186
await this.traceProvider.shutdown();

‎packages/node/test/sdk/client.test.ts‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { AsyncLocalStorage } from 'node:async_hooks';
22
import { ProxyTracer } from '@opentelemetry/api';
33
import type { Event, EventHint, Log } from '@sentry/core';
4-
import { getAsyncContextStrategy, getMainCarrier, Scope, SDK_VERSION } from '@sentry/core';
4+
import {
5+
createTransport,
6+
getAsyncContextStrategy,
7+
getClient,
8+
getMainCarrier,
9+
resolvedSyncPromise,
10+
Scope,
11+
SDK_VERSION,
12+
startSpan,
13+
} from '@sentry/core';
514
import type { SentryTracerProvider } from '@sentry/opentelemetry';
615
import { setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry';
716
import * as SentryOpentelemetry from '@sentry/opentelemetry';
@@ -10,7 +19,7 @@ import * as os from 'os';
1019
import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
1120
import { NodeClient } from '../../src';
1221
import { getDefaultNodeClientOptions } from '../helpers/getDefaultNodeClientOptions';
13-
import { cleanupOtel } from '../helpers/mockSdkInit';
22+
import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit';
1423

1524
describe('NodeClient', () => {
1625
beforeEach(() => {
@@ -420,20 +429,40 @@ describe('NodeClient', () => {
420429
expect(clearIntervalSpy).toHaveBeenCalledWith(intervalResult?.value);
421430
});
422431

423-
it('removes log and metric exit listeners when closed', async () => {
432+
it('removes log, metric and span exit listeners when closed', async () => {
424433
const originalListeners = process.listeners('beforeExit');
425434
const client = new NodeClient(getDefaultNodeClientOptions({ sendClientReports: false }));
426435

427436
const addedListeners = process.listeners('beforeExit').filter(listener => !originalListeners.includes(listener));
428437

429438
await client.close();
430439

431-
expect(addedListeners).toHaveLength(2);
440+
expect(addedListeners).toHaveLength(3);
432441
expect(process.listeners('beforeExit')).toEqual(originalListeners);
433442
});
434443
});
435444

436445
describe('flush', () => {
446+
it('sends buffered streamed spans before the process exits', async () => {
447+
const send = vi.fn(() => resolvedSyncPromise({}));
448+
mockSdkInit({
449+
tracesSampleRate: 1,
450+
traceLifecycle: 'stream',
451+
transport: () => createTransport({ recordDroppedEvent: () => undefined }, send),
452+
});
453+
454+
startSpan({ name: 'cron-job' }, () => {});
455+
await new Promise(resolve => setImmediate(resolve));
456+
expect(send).not.toHaveBeenCalled();
457+
458+
process.emit('beforeExit', 0);
459+
460+
expect(send).toHaveBeenCalledTimes(1);
461+
expect(send).toHaveBeenCalledWith(expect.objectContaining({ body: expect.stringContaining('"type":"span"') }));
462+
463+
await getClient()?.close();
464+
});
465+
437466
it('flush returns immediately when nothing is processing', async () => {
438467
const options = getDefaultNodeClientOptions();
439468
const client = new NodeClient(options);

0 commit comments

Comments
 (0)