Skip to content

Commit b8dc071

Browse files
mydeacadesalaberry
authored andcommitted
feat(node): Simplify SentrySpanProcessor (getsentry#11273)
Instead of extending `BatchSpanProcessor`, we handle this ourselves now. There may be additional improvements we can do here, but this should be OK for now! I added two tests to cover what we really want to achieve/show here: 1. Ensure that if a span & child span are ended after each other, but in the same tick, they are correctly sent. 2. Ensure that if a child span is ended later, it is skipped. By skipping the batched span processor, we can simplify our flushing a bit and also get rid of some of the delays. For now I kept the exporter as a separate class (it is not exported anyhow, so purely internal), we can eventually look into merging this or not, but this is just a moving-parts-around exercise then. This way, tests continued to work mostly, which is good I'd say.
1 parent be5678d commit b8dc071

File tree

9 files changed

+237
-102
lines changed

9 files changed

+237
-102
lines changed

dev-packages/node-integration-tests/suites/tracing-experimental/hapi/test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ describe('hapi auto-instrumentation', () => {
4545

4646
test('CJS - should handle returned plain errors in routes.', done => {
4747
createRunner(__dirname, 'scenario.js')
48+
.expect({
49+
transaction: {
50+
transaction: 'GET /error',
51+
},
52+
})
4853
.expect({ event: EXPECTED_ERROR_EVENT })
4954
.expectError()
5055
.start(done)
@@ -53,6 +58,11 @@ describe('hapi auto-instrumentation', () => {
5358

5459
test('CJS - should handle returned Boom errors in routes.', done => {
5560
createRunner(__dirname, 'scenario.js')
61+
.expect({
62+
transaction: {
63+
transaction: 'GET /boom-error',
64+
},
65+
})
5666
.expect({ event: EXPECTED_ERROR_EVENT })
5767
.expectError()
5868
.start(done)
@@ -61,6 +71,11 @@ describe('hapi auto-instrumentation', () => {
6171

6272
test('CJS - should handle promise rejections in routes.', done => {
6373
createRunner(__dirname, 'scenario.js')
74+
.expect({
75+
transaction: {
76+
transaction: 'GET /promise-error',
77+
},
78+
})
6479
.expect({ event: EXPECTED_ERROR_EVENT })
6580
.expectError()
6681
.start(done)

dev-packages/node-integration-tests/suites/tracing-experimental/mysql/scenario-withoutCallback.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ connection.connect(function (err) {
2424
}
2525
});
2626

27-
Sentry.startSpan(
27+
Sentry.startSpanManual(
2828
{
2929
op: 'transaction',
3030
name: 'Test Transaction',

packages/node-experimental/test/helpers/mockSdkInit.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ProxyTracerProvider, context, propagation, trace } from '@opentelemetry/api';
22
import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
3-
import { getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core';
3+
import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core';
4+
import type { NodeClient } from '../../src';
45

56
import { init } from '../../src/sdk/init';
67
import type { NodeClientOptions } from '../../src/types';
@@ -36,7 +37,7 @@ export function cleanupOtel(_provider?: BasicTracerProvider): void {
3637
}
3738

3839
export function getProvider(_provider?: BasicTracerProvider): BasicTracerProvider | undefined {
39-
let provider = _provider || trace.getTracerProvider();
40+
let provider = _provider || getClient<NodeClient>()?.traceProvider || trace.getTracerProvider();
4041

4142
if (provider instanceof ProxyTracerProvider) {
4243
provider = provider.getDelegate();

packages/node-experimental/test/integration/transactions.test.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,6 @@ describe('Integration | Transactions', () => {
614614

615615
mockSdkInit({ enableTracing: true, beforeSendTransaction });
616616

617-
const client = Sentry.getClient()!;
618617
const provider = getProvider();
619618
const multiSpanProcessor = provider?.activeSpanProcessor as
620619
| (SpanProcessor & { _spanProcessors?: SpanProcessor[] })
@@ -645,24 +644,17 @@ describe('Integration | Transactions', () => {
645644
await new Promise(resolve => setTimeout(resolve, 10 * 60 * 1000));
646645
});
647646

648-
// Nothing added to exporter yet
649-
expect(exporter['_finishedSpans'].length).toBe(0);
650-
651-
void client.flush(5_000);
652-
jest.advanceTimersByTime(5_000);
653-
654-
// Now the child-spans have been added to the exporter, but they are pending since they are waiting for their parant
647+
// Child-spans have been added to the exporter, but they are pending since they are waiting for their parant
655648
expect(exporter['_finishedSpans'].length).toBe(2);
656649
expect(beforeSendTransaction).toHaveBeenCalledTimes(0);
657650

658651
// Now wait for 5 mins
659-
jest.advanceTimersByTime(5 * 60 * 1_000);
652+
jest.advanceTimersByTime(5 * 60 * 1_000 + 1);
660653

661654
// Adding another span will trigger the cleanup
662655
Sentry.startSpan({ name: 'other span' }, () => {});
663656

664-
void client.flush(5_000);
665-
jest.advanceTimersByTime(5_000);
657+
jest.advanceTimersByTime(1);
666658

667659
// Old spans have been cleared away
668660
expect(exporter['_finishedSpans'].length).toBe(0);
@@ -672,7 +664,8 @@ describe('Integration | Transactions', () => {
672664

673665
expect(logs).toEqual(
674666
expect.arrayContaining([
675-
'SpanExporter exported 0 spans, 2 unsent spans remaining',
667+
'SpanExporter has 1 unsent spans remaining',
668+
'SpanExporter has 2 unsent spans remaining',
676669
'SpanExporter exported 1 spans, 2 unsent spans remaining',
677670
`SpanExporter dropping span inner span 1 (${innerSpan1Id}) because it is pending for more than 5 minutes.`,
678671
`SpanExporter dropping span inner span 2 (${innerSpan2Id}) because it is pending for more than 5 minutes.`,

packages/node-experimental/test/sdk/api.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Event } from '@sentry/types';
12
import { getActiveSpan, getClient, startInactiveSpan, startSpan, withActiveSpan } from '../../src';
23
import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit';
34

@@ -64,7 +65,11 @@ describe('withActiveSpan()', () => {
6465
});
6566

6667
it('when `null` is passed, should start a new trace for new spans', async () => {
67-
const beforeSendTransaction = jest.fn(() => null);
68+
const transactions: Event[] = [];
69+
const beforeSendTransaction = jest.fn((event: Event) => {
70+
transactions.push(event);
71+
return null;
72+
});
6873
mockSdkInit({ enableTracing: true, beforeSendTransaction });
6974
const client = getClient();
7075

packages/opentelemetry/src/spanExporter.ts

Lines changed: 56 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import type { Span } from '@opentelemetry/api';
22
import { SpanKind } from '@opentelemetry/api';
3-
import type { ExportResult } from '@opentelemetry/core';
4-
import { ExportResultCode } from '@opentelemetry/core';
5-
import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base';
3+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
64
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
75
import { captureEvent, getMetricSummaryJsonForSpan } from '@sentry/core';
86
import {
@@ -21,6 +19,7 @@ import { convertOtelTimeToSeconds } from './utils/convertOtelTimeToSeconds';
2119
import { getDynamicSamplingContextFromSpan } from './utils/dynamicSamplingContext';
2220
import { getRequestSpanData } from './utils/getRequestSpanData';
2321
import type { SpanNode } from './utils/groupSpansWithParents';
22+
import { getLocalParentId } from './utils/groupSpansWithParents';
2423
import { groupSpansWithParents } from './utils/groupSpansWithParents';
2524
import { mapStatus } from './utils/mapStatus';
2625
import { parseSpanDescription } from './utils/parseSpanDescription';
@@ -31,38 +30,72 @@ type SpanNodeCompleted = SpanNode & { span: ReadableSpan };
3130
/**
3231
* A Sentry-specific exporter that converts OpenTelemetry Spans to Sentry Spans & Transactions.
3332
*/
34-
export class SentrySpanExporter implements SpanExporter {
33+
export class SentrySpanExporter {
34+
private _flushTimeout: ReturnType<typeof setTimeout> | undefined;
3535
private _finishedSpans: ReadableSpan[];
36-
private _stopped: boolean;
3736

3837
public constructor() {
39-
this._stopped = false;
4038
this._finishedSpans = [];
4139
}
4240

43-
/** @inheritDoc */
44-
public export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {
45-
if (this._stopped) {
46-
return resultCallback({
47-
code: ExportResultCode.FAILED,
48-
error: new Error('Exporter has been stopped'),
49-
});
41+
/** Export a single span. */
42+
public export(span: ReadableSpan): void {
43+
this._finishedSpans.push(span);
44+
45+
// If the span has a local parent ID, we don't need to export anything just yet
46+
if (getLocalParentId(span)) {
47+
const openSpanCount = this._finishedSpans.length;
48+
DEBUG_BUILD && logger.log(`SpanExporter has ${openSpanCount} unsent spans remaining`);
49+
this._cleanupOldSpans();
50+
return;
5051
}
5152

52-
const openSpanCount = this._finishedSpans.length;
53-
const newSpanCount = spans.length;
53+
this._clearTimeout();
54+
55+
// If we got a parent span, we try to send the span tree
56+
// Wait a tick for this, to ensure we avoid race conditions
57+
this._flushTimeout = setTimeout(() => {
58+
this.flush();
59+
}, 1);
60+
}
61+
62+
/** Try to flush any pending spans immediately. */
63+
public flush(): void {
64+
this._clearTimeout();
5465

55-
this._finishedSpans.push(...spans);
66+
const openSpanCount = this._finishedSpans.length;
5667

5768
const remainingSpans = maybeSend(this._finishedSpans);
5869

5970
const remainingOpenSpanCount = remainingSpans.length;
60-
const sentSpanCount = openSpanCount + newSpanCount - remainingOpenSpanCount;
71+
const sentSpanCount = openSpanCount - remainingOpenSpanCount;
6172

6273
DEBUG_BUILD &&
6374
logger.log(`SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} unsent spans remaining`);
6475

65-
this._finishedSpans = remainingSpans.filter(span => {
76+
this._cleanupOldSpans(remainingSpans);
77+
}
78+
79+
/** Clear the exporter. */
80+
public clear(): void {
81+
this._finishedSpans = [];
82+
this._clearTimeout();
83+
}
84+
85+
/** Clear the flush timeout. */
86+
private _clearTimeout(): void {
87+
if (this._flushTimeout) {
88+
clearTimeout(this._flushTimeout);
89+
this._flushTimeout = undefined;
90+
}
91+
}
92+
93+
/**
94+
* Remove any span that is older than 5min.
95+
* We do this to avoid leaking memory.
96+
*/
97+
private _cleanupOldSpans(spans = this._finishedSpans): void {
98+
this._finishedSpans = spans.filter(span => {
6699
const shouldDrop = shouldCleanupSpan(span, 5 * 60);
67100
DEBUG_BUILD &&
68101
shouldDrop &&
@@ -73,23 +106,6 @@ export class SentrySpanExporter implements SpanExporter {
73106
);
74107
return !shouldDrop;
75108
});
76-
77-
resultCallback({ code: ExportResultCode.SUCCESS });
78-
}
79-
80-
/** @inheritDoc */
81-
public shutdown(): Promise<void> {
82-
const forceFlush = this.forceFlush();
83-
this._stopped = true;
84-
this._finishedSpans = [];
85-
return forceFlush;
86-
}
87-
88-
/** @inheritDoc */
89-
public forceFlush(): Promise<void> {
90-
return new Promise(resolve => {
91-
this.export(this._finishedSpans, () => resolve());
92-
});
93109
}
94110
}
95111

@@ -132,8 +148,12 @@ function maybeSend(spans: ReadableSpan[]): ReadableSpan[] {
132148
.filter((span): span is ReadableSpan => !!span);
133149
}
134150

151+
function nodeIsCompletedRootNode(node: SpanNode): node is SpanNodeCompleted {
152+
return !!node.span && !node.parentNode;
153+
}
154+
135155
function getCompletedRootNodes(nodes: SpanNode[]): SpanNodeCompleted[] {
136-
return nodes.filter((node): node is SpanNodeCompleted => !!node.span && !node.parentNode);
156+
return nodes.filter(nodeIsCompletedRootNode);
137157
}
138158

139159
function shouldCleanupSpan(span: ReadableSpan, maxStartTimeOffsetSeconds: number): boolean {

packages/opentelemetry/src/spanProcessor.ts

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { Context } from '@opentelemetry/api';
2-
import { ROOT_CONTEXT, trace } from '@opentelemetry/api';
3-
import type { Span, SpanProcessor as SpanProcessorInterface } from '@opentelemetry/sdk-trace-base';
4-
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
2+
import { ROOT_CONTEXT, TraceFlags, trace } from '@opentelemetry/api';
3+
import type { ReadableSpan, Span, SpanProcessor as SpanProcessorInterface } from '@opentelemetry/sdk-trace-base';
54
import { addChildSpanToSpan, getClient, getDefaultCurrentScope, getDefaultIsolationScope } from '@sentry/core';
65
import { logger } from '@sentry/utils';
76

@@ -46,20 +45,35 @@ function onSpanStart(span: Span, parentContext: Context): void {
4645
client?.emit('spanStart', span);
4746
}
4847

49-
function onSpanEnd(span: Span): void {
48+
function onSpanEnd(span: ReadableSpan): void {
5049
const client = getClient();
51-
client?.emit('spanEnd', span);
50+
client?.emit('spanEnd', span as Span);
5251
}
5352

5453
/**
5554
* Converts OpenTelemetry Spans to Sentry Spans and sends them to Sentry via
5655
* the Sentry SDK.
5756
*/
58-
export class SentrySpanProcessor extends BatchSpanProcessor implements SpanProcessorInterface {
59-
public constructor() {
60-
super(new SentrySpanExporter());
57+
export class SentrySpanProcessor implements SpanProcessorInterface {
58+
private _exporter: SentrySpanExporter;
6159

60+
public constructor() {
6261
setIsSetup('SentrySpanProcessor');
62+
this._exporter = new SentrySpanExporter();
63+
}
64+
65+
/**
66+
* @inheritDoc
67+
*/
68+
public async forceFlush(): Promise<void> {
69+
this._exporter.flush();
70+
}
71+
72+
/**
73+
* @inheritDoc
74+
*/
75+
public async shutdown(): Promise<void> {
76+
this._exporter.clear();
6377
}
6478

6579
/**
@@ -72,29 +86,19 @@ export class SentrySpanProcessor extends BatchSpanProcessor implements SpanProce
7286
// once we decoupled opentelemetry from SentrySpan
7387

7488
DEBUG_BUILD && logger.log(`[Tracing] Starting span "${span.name}" (${span.spanContext().spanId})`);
75-
76-
return super.onStart(span, parentContext);
7789
}
7890

7991
/** @inheritDoc */
80-
public onEnd(span: Span): void {
81-
DEBUG_BUILD && logger.log(`[Tracing] Finishing span "${span.name}" (${span.spanContext().spanId})`);
82-
83-
if (!this._shouldSendSpanToSentry(span)) {
84-
// Prevent this being called to super.onEnd(), which would pass this to the span exporter
92+
public onEnd(span: ReadableSpan): void {
93+
if (span.spanContext().traceFlags !== TraceFlags.SAMPLED) {
94+
DEBUG_BUILD && logger.log(`[Tracing] Finishing unsampled span "${span.name}" (${span.spanContext().spanId})`);
8595
return;
8696
}
8797

88-
onSpanEnd(span);
98+
DEBUG_BUILD && logger.log(`[Tracing] Finishing span "${span.name}" (${span.spanContext().spanId})`);
8999

90-
return super.onEnd(span);
91-
}
100+
onSpanEnd(span);
92101

93-
/**
94-
* You can overwrite this in a sub class to implement custom behavior for dropping spans.
95-
* If you return `false` here, the span will not be passed to the exporter and thus not be sent.
96-
*/
97-
protected _shouldSendSpanToSentry(_span: Span): boolean {
98-
return true;
102+
this._exporter.export(span);
99103
}
100104
}

packages/opentelemetry/src/utils/groupSpansWithParents.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,19 @@ export function groupSpansWithParents(spans: ReadableSpan[]): SpanNode[] {
2626
});
2727
}
2828

29-
function createOrUpdateSpanNodeAndRefs(nodeMap: SpanMap, span: ReadableSpan): void {
29+
/**
30+
* This returns the _local_ parent ID - `parentId` on the span may point to a remote span.
31+
*/
32+
export function getLocalParentId(span: ReadableSpan): string | undefined {
3033
const parentIsRemote = span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE] === true;
31-
32-
const id = span.spanContext().spanId;
33-
3434
// If the parentId is the trace parent ID, we pretend it's undefined
3535
// As this means the parent exists somewhere else
36-
const parentId = !parentIsRemote ? span.parentSpanId : undefined;
36+
return !parentIsRemote ? span.parentSpanId : undefined;
37+
}
38+
39+
function createOrUpdateSpanNodeAndRefs(nodeMap: SpanMap, span: ReadableSpan): void {
40+
const id = span.spanContext().spanId;
41+
const parentId = getLocalParentId(span);
3742

3843
if (!parentId) {
3944
createOrUpdateNode(nodeMap, { id, span, children: [] });

0 commit comments

Comments
 (0)