Skip to content

Commit b0908d0

Browse files
authored
feat(otel): Map otel span data to Sentry transaction/span/context (#6082)
Enhance sentry transactions/spans/context with data from OpenTelemetry spans, when using otel-integration. * otel attributes, resource for transaction --> sentry context * otel attributes, kind for span --> sentry data * otel status + data from attributes --> sentry status (for both transactions & spans)
1 parent c6bdbfd commit b0908d0

File tree

4 files changed

+323
-14
lines changed

4 files changed

+323
-14
lines changed

packages/opentelemetry-node/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
},
2525
"peerDependencies": {
2626
"@opentelemetry/api": "1.x",
27-
"@opentelemetry/sdk-trace-base": "1.x"
27+
"@opentelemetry/sdk-trace-base": "1.x",
28+
"@opentelemetry/semantic-conventions": "1.x"
2829
},
2930
"devDependencies": {
3031
"@opentelemetry/api": "^1.2.0",

packages/opentelemetry-node/src/spanprocessor.ts

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { Context } from '@opentelemetry/api';
22
import { Span as OtelSpan, SpanProcessor as OtelSpanProcessor } from '@opentelemetry/sdk-trace-base';
3-
import { getCurrentHub } from '@sentry/core';
3+
import { getCurrentHub, withScope } from '@sentry/core';
4+
import { Transaction } from '@sentry/tracing';
45
import { Span as SentrySpan, TransactionContext } from '@sentry/types';
56
import { logger } from '@sentry/utils';
67

8+
import { mapOtelStatus } from './utils/map-otel-status';
9+
710
/**
811
* Converts OpenTelemetry Spans to Sentry Spans and sends them to Sentry via
912
* the Sentry SDK.
@@ -65,20 +68,21 @@ export class SentrySpanProcessor implements OtelSpanProcessor {
6568
*/
6669
public onEnd(otelSpan: OtelSpan): void {
6770
const otelSpanId = otelSpan.spanContext().spanId;
68-
const mapVal = this._map.get(otelSpanId);
71+
const sentrySpan = this._map.get(otelSpanId);
6972

70-
if (!mapVal) {
73+
if (!sentrySpan) {
7174
__DEBUG_BUILD__ &&
7275
logger.error(`SentrySpanProcessor could not find span with OTEL-spanId ${otelSpanId} to finish.`);
7376
return;
7477
}
7578

76-
const sentrySpan = mapVal;
77-
78-
// TODO: actually add context etc. to span
79-
// updateSpanWithOtelData(sentrySpan, otelSpan);
80-
81-
sentrySpan.finish(otelSpan.endTime[0]);
79+
if (sentrySpan instanceof Transaction) {
80+
updateTransactionWithOtelData(sentrySpan, otelSpan);
81+
finishTransactionWithContextFromOtelData(sentrySpan, otelSpan);
82+
} else {
83+
updateSpanWithOtelData(sentrySpan, otelSpan);
84+
sentrySpan.finish(otelSpan.endTime[0]);
85+
}
8286

8387
this._map.delete(otelSpanId);
8488
}
@@ -111,5 +115,29 @@ function getTraceData(otelSpan: OtelSpan): Partial<TransactionContext> {
111115
return { spanId, traceId, parentSpanId };
112116
}
113117

114-
// function updateSpanWithOtelData(sentrySpan: SentrySpan, otelSpan: OtelSpan): void {
115-
// }
118+
function finishTransactionWithContextFromOtelData(transaction: Transaction, otelSpan: OtelSpan): void {
119+
withScope(scope => {
120+
scope.setContext('otel', {
121+
attributes: otelSpan.attributes,
122+
resource: otelSpan.resource.attributes,
123+
});
124+
125+
transaction.finish(otelSpan.endTime[0]);
126+
});
127+
}
128+
129+
function updateSpanWithOtelData(sentrySpan: SentrySpan, otelSpan: OtelSpan): void {
130+
const { attributes, kind } = otelSpan;
131+
132+
sentrySpan.setStatus(mapOtelStatus(otelSpan));
133+
sentrySpan.setData('otel.kind', kind.valueOf());
134+
135+
Object.keys(attributes).forEach(prop => {
136+
const value = attributes[prop];
137+
sentrySpan.setData(prop, value);
138+
});
139+
}
140+
141+
function updateTransactionWithOtelData(transaction: Transaction, otelSpan: OtelSpan): void {
142+
transaction.setStatus(mapOtelStatus(otelSpan));
143+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Span as OtelSpan } from '@opentelemetry/sdk-trace-base';
2+
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
3+
import { SpanStatusType as SentryStatus } from '@sentry/tracing';
4+
5+
// canonicalCodesHTTPMap maps some HTTP codes to Sentry's span statuses. See possible mapping in https://develop.sentry.dev/sdk/event-payloads/span/
6+
const canonicalCodesHTTPMap: Record<string, SentryStatus> = {
7+
'400': 'failed_precondition',
8+
'401': 'unauthenticated',
9+
'403': 'permission_denied',
10+
'404': 'not_found',
11+
'409': 'aborted',
12+
'429': 'resource_exhausted',
13+
'499': 'cancelled',
14+
'500': 'internal_error',
15+
'501': 'unimplemented',
16+
'503': 'unavailable',
17+
'504': 'deadline_exceeded',
18+
} as const;
19+
20+
// canonicalCodesGrpcMap maps some GRPC codes to Sentry's span statuses. See description in grpc documentation.
21+
const canonicalCodesGrpcMap: Record<string, SentryStatus> = {
22+
'1': 'cancelled',
23+
'2': 'unknown_error',
24+
'3': 'invalid_argument',
25+
'4': 'deadline_exceeded',
26+
'5': 'not_found',
27+
'6': 'already_exists',
28+
'7': 'permission_denied',
29+
'8': 'resource_exhausted',
30+
'9': 'failed_precondition',
31+
'10': 'aborted',
32+
'11': 'out_of_range',
33+
'12': 'unimplemented',
34+
'13': 'internal_error',
35+
'14': 'unavailable',
36+
'15': 'data_loss',
37+
'16': 'unauthenticated',
38+
} as const;
39+
40+
/**
41+
* Get a Sentry span status from an otel span.
42+
*
43+
* @param otelSpan An otel span to generate a sentry status for.
44+
* @returns The Sentry span status
45+
*/
46+
export function mapOtelStatus(otelSpan: OtelSpan): SentryStatus {
47+
const { status, attributes } = otelSpan;
48+
49+
const statusCode = status.code;
50+
51+
if (statusCode < 0 || statusCode > 2) {
52+
return 'unknown_error';
53+
}
54+
55+
if (statusCode === 0 || statusCode === 1) {
56+
return 'ok';
57+
}
58+
59+
const httpCode = attributes[SemanticAttributes.HTTP_STATUS_CODE];
60+
const grpcCode = attributes[SemanticAttributes.RPC_GRPC_STATUS_CODE];
61+
62+
if (typeof httpCode === 'string') {
63+
const sentryStatus = canonicalCodesHTTPMap[httpCode];
64+
if (sentryStatus) {
65+
return sentryStatus;
66+
}
67+
}
68+
69+
if (typeof grpcCode === 'string') {
70+
const sentryStatus = canonicalCodesGrpcMap[grpcCode];
71+
if (sentryStatus) {
72+
return sentryStatus;
73+
}
74+
}
75+
76+
return 'unknown_error';
77+
}

packages/opentelemetry-node/test/spanprocessor.test.ts

Lines changed: 205 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import * as OpenTelemetry from '@opentelemetry/api';
2+
import { Resource } from '@opentelemetry/resources';
23
import { Span as OtelSpan } from '@opentelemetry/sdk-trace-base';
34
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
5+
import { SemanticAttributes, SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
46
import { Hub, makeMain } from '@sentry/core';
5-
import { addExtensionMethods, Span as SentrySpan, Transaction } from '@sentry/tracing';
7+
import { addExtensionMethods, Span as SentrySpan, SpanStatusType, Transaction } from '@sentry/tracing';
8+
import { Contexts, Scope } from '@sentry/types';
69

710
import { SentrySpanProcessor } from '../src/spanprocessor';
811

@@ -22,7 +25,11 @@ describe('SentrySpanProcessor', () => {
2225
makeMain(hub);
2326

2427
spanProcessor = new SentrySpanProcessor();
25-
provider = new NodeTracerProvider();
28+
provider = new NodeTracerProvider({
29+
resource: new Resource({
30+
[SemanticResourceAttributes.SERVICE_NAME]: 'test-service',
31+
}),
32+
});
2633
provider.addSpanProcessor(spanProcessor);
2734
provider.register();
2835
});
@@ -36,6 +43,27 @@ describe('SentrySpanProcessor', () => {
3643
return spanProcessor._map.get(otelSpan.spanContext().spanId);
3744
}
3845

46+
function getContext(transaction: Transaction) {
47+
const transactionWithContext = transaction as unknown as Transaction & { _contexts: Contexts };
48+
return transactionWithContext._contexts;
49+
}
50+
51+
// monkey-patch finish to store the context at finish time
52+
function monkeyPatchTransactionFinish(transaction: Transaction) {
53+
const monkeyPatchedTransaction = transaction as Transaction & { _contexts: Contexts };
54+
55+
// eslint-disable-next-line @typescript-eslint/unbound-method
56+
const originalFinish = monkeyPatchedTransaction.finish;
57+
monkeyPatchedTransaction._contexts = {};
58+
monkeyPatchedTransaction.finish = function (endTimestamp?: number | undefined) {
59+
monkeyPatchedTransaction._contexts = (
60+
transaction._hub.getScope() as unknown as Scope & { _contexts: Contexts }
61+
)._contexts;
62+
63+
return originalFinish.apply(monkeyPatchedTransaction, [endTimestamp]);
64+
};
65+
}
66+
3967
it('creates a transaction', async () => {
4068
const startTime = otelNumberToHrtime(new Date().valueOf());
4169

@@ -125,6 +153,181 @@ describe('SentrySpanProcessor', () => {
125153
parentOtelSpan.end();
126154
});
127155
});
156+
157+
it('sets context for transaction', async () => {
158+
const otelSpan = provider.getTracer('default').startSpan('GET /users');
159+
160+
const transaction = getSpanForOtelSpan(otelSpan) as Transaction;
161+
monkeyPatchTransactionFinish(transaction);
162+
163+
// context is only set after end
164+
expect(getContext(transaction)).toEqual({});
165+
166+
otelSpan.end();
167+
168+
expect(getContext(transaction)).toEqual({
169+
otel: {
170+
attributes: {},
171+
resource: {
172+
'service.name': 'test-service',
173+
'telemetry.sdk.language': 'nodejs',
174+
'telemetry.sdk.name': 'opentelemetry',
175+
'telemetry.sdk.version': '1.7.0',
176+
},
177+
},
178+
});
179+
180+
// Start new transaction
181+
const otelSpan2 = provider.getTracer('default').startSpan('GET /companies');
182+
183+
const transaction2 = getSpanForOtelSpan(otelSpan2) as Transaction;
184+
monkeyPatchTransactionFinish(transaction2);
185+
186+
expect(getContext(transaction2)).toEqual({});
187+
188+
otelSpan2.setAttribute('test-attribute', 'test-value');
189+
190+
otelSpan2.end();
191+
192+
expect(getContext(transaction2)).toEqual({
193+
otel: {
194+
attributes: {
195+
'test-attribute': 'test-value',
196+
},
197+
resource: {
198+
'service.name': 'test-service',
199+
'telemetry.sdk.language': 'nodejs',
200+
'telemetry.sdk.name': 'opentelemetry',
201+
'telemetry.sdk.version': '1.7.0',
202+
},
203+
},
204+
});
205+
});
206+
207+
it('sets data for span', async () => {
208+
const tracer = provider.getTracer('default');
209+
210+
tracer.startActiveSpan('GET /users', parentOtelSpan => {
211+
tracer.startActiveSpan('SELECT * FROM users;', child => {
212+
child.setAttribute('test-attribute', 'test-value');
213+
child.setAttribute('test-attribute-2', [1, 2, 3]);
214+
child.setAttribute('test-attribute-3', 0);
215+
child.setAttribute('test-attribute-4', false);
216+
217+
const sentrySpan = getSpanForOtelSpan(child);
218+
219+
expect(sentrySpan?.data).toEqual({});
220+
221+
child.end();
222+
223+
expect(sentrySpan?.data).toEqual({
224+
'otel.kind': 0,
225+
'test-attribute': 'test-value',
226+
'test-attribute-2': [1, 2, 3],
227+
'test-attribute-3': 0,
228+
'test-attribute-4': false,
229+
});
230+
});
231+
232+
parentOtelSpan.end();
233+
});
234+
});
235+
236+
it('sets status for transaction', async () => {
237+
const otelSpan = provider.getTracer('default').startSpan('GET /users');
238+
239+
const transaction = getSpanForOtelSpan(otelSpan) as Transaction;
240+
241+
// status is only set after end
242+
expect(transaction?.status).toBe(undefined);
243+
244+
otelSpan.end();
245+
246+
expect(transaction?.status).toBe('ok');
247+
});
248+
249+
it('sets status for span', async () => {
250+
const tracer = provider.getTracer('default');
251+
252+
tracer.startActiveSpan('GET /users', parentOtelSpan => {
253+
tracer.startActiveSpan('SELECT * FROM users;', child => {
254+
const sentrySpan = getSpanForOtelSpan(child);
255+
256+
expect(sentrySpan?.status).toBe(undefined);
257+
258+
child.end();
259+
260+
expect(sentrySpan?.status).toBe('ok');
261+
262+
parentOtelSpan.end();
263+
});
264+
});
265+
});
266+
267+
const statusTestTable: [number, undefined | string, undefined | string, SpanStatusType][] = [
268+
[-1, undefined, undefined, 'unknown_error'],
269+
[3, undefined, undefined, 'unknown_error'],
270+
[0, undefined, undefined, 'ok'],
271+
[1, undefined, undefined, 'ok'],
272+
[2, undefined, undefined, 'unknown_error'],
273+
274+
// http codes
275+
[2, '400', undefined, 'failed_precondition'],
276+
[2, '401', undefined, 'unauthenticated'],
277+
[2, '403', undefined, 'permission_denied'],
278+
[2, '404', undefined, 'not_found'],
279+
[2, '409', undefined, 'aborted'],
280+
[2, '429', undefined, 'resource_exhausted'],
281+
[2, '499', undefined, 'cancelled'],
282+
[2, '500', undefined, 'internal_error'],
283+
[2, '501', undefined, 'unimplemented'],
284+
[2, '503', undefined, 'unavailable'],
285+
[2, '504', undefined, 'deadline_exceeded'],
286+
[2, '999', undefined, 'unknown_error'],
287+
288+
// grpc codes
289+
[2, undefined, '1', 'cancelled'],
290+
[2, undefined, '2', 'unknown_error'],
291+
[2, undefined, '3', 'invalid_argument'],
292+
[2, undefined, '4', 'deadline_exceeded'],
293+
[2, undefined, '5', 'not_found'],
294+
[2, undefined, '6', 'already_exists'],
295+
[2, undefined, '7', 'permission_denied'],
296+
[2, undefined, '8', 'resource_exhausted'],
297+
[2, undefined, '9', 'failed_precondition'],
298+
[2, undefined, '10', 'aborted'],
299+
[2, undefined, '11', 'out_of_range'],
300+
[2, undefined, '12', 'unimplemented'],
301+
[2, undefined, '13', 'internal_error'],
302+
[2, undefined, '14', 'unavailable'],
303+
[2, undefined, '15', 'data_loss'],
304+
[2, undefined, '16', 'unauthenticated'],
305+
[2, undefined, '999', 'unknown_error'],
306+
307+
// http takes precedence over grpc
308+
[2, '400', '2', 'failed_precondition'],
309+
];
310+
311+
it.each(statusTestTable)(
312+
'correctly converts otel span status to sentry status with otelStatus=%i, httpCode=%s, grpcCode=%s',
313+
(otelStatus, httpCode, grpcCode, expected) => {
314+
const otelSpan = provider.getTracer('default').startSpan('GET /users');
315+
const transaction = getSpanForOtelSpan(otelSpan) as Transaction;
316+
317+
otelSpan.setStatus({ code: otelStatus });
318+
319+
if (httpCode) {
320+
otelSpan.setAttribute(SemanticAttributes.HTTP_STATUS_CODE, httpCode);
321+
}
322+
323+
if (grpcCode) {
324+
otelSpan.setAttribute(SemanticAttributes.RPC_GRPC_STATUS_CODE, grpcCode);
325+
}
326+
327+
otelSpan.end();
328+
expect(transaction?.status).toBe(expected);
329+
},
330+
);
128331
});
129332

130333
// OTEL expects a custom date format

0 commit comments

Comments
 (0)