Skip to content

Commit 3339829

Browse files
authored
feat(opentelemetry): Set response context for http.server spans (#14634)
This implements the `response` context for http.server spans (https://develop.sentry.dev/sdk/data-model/event-payloads/contexts/#response-context). I opted to not implement this for the browser, as we do not really expect server spans there. I added a test there anyhow to show the shape of the transaction event, so we can adjust this easier if we want in the future. Closes #14619 Closes #14634
1 parent 71a5a7b commit 3339829

File tree

5 files changed

+201
-29
lines changed

5 files changed

+201
-29
lines changed

dev-packages/e2e-tests/test-applications/nestjs-8/tests/transactions.test.ts

+4
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ test('Sends an API route transaction', async ({ baseURL }) => {
4646
origin: 'auto.http.otel.http',
4747
});
4848

49+
expect(transactionEvent.contexts?.response).toEqual({
50+
status_code: 200,
51+
});
52+
4953
expect(transactionEvent).toEqual(
5054
expect.objectContaining({
5155
spans: expect.arrayContaining([

dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts

+4
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ test('Sends an API route transaction', async ({ baseURL }) => {
4646
origin: 'auto.http.otel.http',
4747
});
4848

49+
expect(transactionEvent.contexts?.response).toEqual({
50+
status_code: 200,
51+
});
52+
4953
expect(transactionEvent).toEqual(
5054
expect.objectContaining({
5155
transaction: 'GET /test-transaction',

packages/core/test/lib/tracing/sentrySpan.test.ts

+64-24
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCurrentClient, timestampInSeconds } from '../../../src';
1+
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getCurrentScope, setCurrentClient, timestampInSeconds } from '../../../src';
22
import { SentrySpan } from '../../../src/tracing/sentrySpan';
33
import { SPAN_STATUS_ERROR } from '../../../src/tracing/spanstatus';
44
import { TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED, spanToJSON } from '../../../src/utils/spanUtils';
@@ -95,14 +95,30 @@ describe('SentrySpan', () => {
9595
});
9696
});
9797

98-
describe('finish', () => {
98+
describe('end', () => {
9999
test('simple', () => {
100100
const span = new SentrySpan({});
101101
expect(spanToJSON(span).timestamp).toBeUndefined();
102102
span.end();
103103
expect(spanToJSON(span).timestamp).toBeGreaterThan(1);
104104
});
105105

106+
test('with endTime in seconds', () => {
107+
const span = new SentrySpan({});
108+
expect(spanToJSON(span).timestamp).toBeUndefined();
109+
const endTime = Date.now() / 1000;
110+
span.end(endTime);
111+
expect(spanToJSON(span).timestamp).toBe(endTime);
112+
});
113+
114+
test('with endTime in milliseconds', () => {
115+
const span = new SentrySpan({});
116+
expect(spanToJSON(span).timestamp).toBeUndefined();
117+
const endTime = Date.now();
118+
span.end(endTime);
119+
expect(spanToJSON(span).timestamp).toBe(endTime / 1000);
120+
});
121+
106122
test('uses sampled config for standalone span', () => {
107123
const client = new TestClient(
108124
getDefaultTestClientOptions({
@@ -136,7 +152,7 @@ describe('SentrySpan', () => {
136152
expect(mockSend).toHaveBeenCalledTimes(1);
137153
});
138154

139-
test('sends the span if `beforeSendSpan` does not modify the span ', () => {
155+
test('sends the span if `beforeSendSpan` does not modify the span', () => {
140156
const beforeSendSpan = jest.fn(span => span);
141157
const client = new TestClient(
142158
getDefaultTestClientOptions({
@@ -194,30 +210,54 @@ describe('SentrySpan', () => {
194210
);
195211
consoleWarnSpy.mockRestore();
196212
});
197-
});
198213

199-
describe('end', () => {
200-
test('simple', () => {
201-
const span = new SentrySpan({});
202-
expect(spanToJSON(span).timestamp).toBeUndefined();
203-
span.end();
204-
expect(spanToJSON(span).timestamp).toBeGreaterThan(1);
205-
});
214+
test('build TransactionEvent for basic root span', () => {
215+
const client = new TestClient(
216+
getDefaultTestClientOptions({
217+
dsn: 'https://username@domain/123',
218+
}),
219+
);
220+
setCurrentClient(client);
206221

207-
test('with endTime in seconds', () => {
208-
const span = new SentrySpan({});
209-
expect(spanToJSON(span).timestamp).toBeUndefined();
210-
const endTime = Date.now() / 1000;
211-
span.end(endTime);
212-
expect(spanToJSON(span).timestamp).toBe(endTime);
213-
});
222+
const scope = getCurrentScope();
223+
const captureEventSpy = jest.spyOn(scope, 'captureEvent').mockImplementation(() => 'testId');
214224

215-
test('with endTime in milliseconds', () => {
216-
const span = new SentrySpan({});
217-
expect(spanToJSON(span).timestamp).toBeUndefined();
218-
const endTime = Date.now();
219-
span.end(endTime);
220-
expect(spanToJSON(span).timestamp).toBe(endTime / 1000);
225+
const span = new SentrySpan({
226+
name: 'test',
227+
startTimestamp: 1,
228+
sampled: true,
229+
});
230+
span.end(2);
231+
232+
expect(captureEventSpy).toHaveBeenCalledTimes(1);
233+
expect(captureEventSpy).toHaveBeenCalledWith({
234+
_metrics_summary: undefined,
235+
contexts: {
236+
trace: {
237+
data: {
238+
'sentry.origin': 'manual',
239+
},
240+
origin: 'manual',
241+
span_id: expect.stringMatching(/^[a-f0-9]{16}$/),
242+
trace_id: expect.stringMatching(/^[a-f0-9]{32}$/),
243+
},
244+
},
245+
sdkProcessingMetadata: {
246+
capturedSpanIsolationScope: undefined,
247+
capturedSpanScope: undefined,
248+
dynamicSamplingContext: {
249+
environment: 'production',
250+
public_key: 'username',
251+
trace_id: expect.stringMatching(/^[a-f0-9]{32}$/),
252+
transaction: 'test',
253+
},
254+
},
255+
spans: [],
256+
start_timestamp: 1,
257+
timestamp: 2,
258+
transaction: 'test',
259+
type: 'transaction',
260+
});
221261
});
222262
});
223263

packages/opentelemetry/src/spanExporter.ts

+18-5
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
/* eslint-disable max-lines */
12
import type { Span } from '@opentelemetry/api';
23
import { SpanKind } from '@opentelemetry/api';
34
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
45
import { ATTR_HTTP_RESPONSE_STATUS_CODE, SEMATTRS_HTTP_STATUS_CODE } from '@opentelemetry/semantic-conventions';
5-
import type { SpanJSON, SpanOrigin, TraceContext, TransactionEvent, TransactionSource } from '@sentry/core';
6+
import type {
7+
SpanAttributes,
8+
SpanJSON,
9+
SpanOrigin,
10+
TraceContext,
11+
TransactionEvent,
12+
TransactionSource,
13+
} from '@sentry/core';
614
import {
715
SEMANTIC_ATTRIBUTE_SENTRY_OP,
816
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
@@ -221,13 +229,14 @@ function parseSpan(span: ReadableSpan): { op?: string; origin?: SpanOrigin; sour
221229
return { origin, op, source };
222230
}
223231

224-
function createTransactionForOtelSpan(span: ReadableSpan): TransactionEvent {
232+
/** Exported only for tests. */
233+
export function createTransactionForOtelSpan(span: ReadableSpan): TransactionEvent {
225234
const { op, description, data, origin = 'manual', source } = getSpanData(span);
226235
const capturedSpanScopes = getCapturedScopesOnSpan(span as unknown as Span);
227236

228237
const sampleRate = span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE] as number | undefined;
229238

230-
const attributes = dropUndefinedKeys({
239+
const attributes: SpanAttributes = dropUndefinedKeys({
231240
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
232241
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: sampleRate,
233242
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
@@ -257,12 +266,16 @@ function createTransactionForOtelSpan(span: ReadableSpan): TransactionEvent {
257266
status: getStatusMessage(status), // As per protocol, span status is allowed to be undefined
258267
});
259268

260-
const transactionEvent: TransactionEvent = {
269+
const statusCode = attributes[ATTR_HTTP_RESPONSE_STATUS_CODE];
270+
const responseContext = typeof statusCode === 'number' ? { response: { status_code: statusCode } } : undefined;
271+
272+
const transactionEvent: TransactionEvent = dropUndefinedKeys({
261273
contexts: {
262274
trace: traceContext,
263275
otel: {
264276
resource: span.resource.attributes,
265277
},
278+
...responseContext,
266279
},
267280
spans: [],
268281
start_timestamp: spanTimeInputToSeconds(span.startTime),
@@ -283,7 +296,7 @@ function createTransactionForOtelSpan(span: ReadableSpan): TransactionEvent {
283296
},
284297
}),
285298
_metrics_summary: getMetricSummaryJsonForSpan(span as unknown as Span),
286-
};
299+
});
287300

288301
return transactionEvent;
289302
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { ATTR_HTTP_RESPONSE_STATUS_CODE } from '@opentelemetry/semantic-conventions';
2+
import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, startInactiveSpan } from '@sentry/core';
3+
import { createTransactionForOtelSpan } from '../src/spanExporter';
4+
import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit';
5+
6+
describe('createTransactionForOtelSpan', () => {
7+
beforeEach(() => {
8+
mockSdkInit({
9+
enableTracing: true,
10+
});
11+
});
12+
13+
afterEach(() => {
14+
cleanupOtel();
15+
});
16+
17+
it('works with a basic span', () => {
18+
const span = startInactiveSpan({ name: 'test', startTime: 1733821670000 });
19+
span.end(1733821672000);
20+
21+
const event = createTransactionForOtelSpan(span as any);
22+
// we do not care about this here
23+
delete event.sdkProcessingMetadata;
24+
25+
expect(event).toEqual({
26+
contexts: {
27+
trace: {
28+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
29+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
30+
data: {
31+
'sentry.source': 'custom',
32+
'sentry.sample_rate': 1,
33+
'sentry.origin': 'manual',
34+
},
35+
origin: 'manual',
36+
status: 'ok',
37+
},
38+
otel: {
39+
resource: {
40+
'service.name': 'opentelemetry-test',
41+
'telemetry.sdk.language': 'nodejs',
42+
'telemetry.sdk.name': 'opentelemetry',
43+
'telemetry.sdk.version': expect.any(String),
44+
'service.namespace': 'sentry',
45+
'service.version': SDK_VERSION,
46+
},
47+
},
48+
},
49+
spans: [],
50+
start_timestamp: 1733821670,
51+
timestamp: 1733821672,
52+
transaction: 'test',
53+
type: 'transaction',
54+
transaction_info: { source: 'custom' },
55+
});
56+
});
57+
58+
it('works with a http.server span', () => {
59+
const span = startInactiveSpan({
60+
name: 'test',
61+
startTime: 1733821670000,
62+
attributes: {
63+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
64+
[ATTR_HTTP_RESPONSE_STATUS_CODE]: 200,
65+
},
66+
});
67+
span.end(1733821672000);
68+
69+
const event = createTransactionForOtelSpan(span as any);
70+
// we do not care about this here
71+
delete event.sdkProcessingMetadata;
72+
73+
expect(event).toEqual({
74+
contexts: {
75+
trace: {
76+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
77+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
78+
data: {
79+
'sentry.source': 'custom',
80+
'sentry.sample_rate': 1,
81+
'sentry.origin': 'manual',
82+
'sentry.op': 'http.server',
83+
'http.response.status_code': 200,
84+
},
85+
origin: 'manual',
86+
status: 'ok',
87+
op: 'http.server',
88+
},
89+
otel: {
90+
resource: {
91+
'service.name': 'opentelemetry-test',
92+
'telemetry.sdk.language': 'nodejs',
93+
'telemetry.sdk.name': 'opentelemetry',
94+
'telemetry.sdk.version': expect.any(String),
95+
'service.namespace': 'sentry',
96+
'service.version': SDK_VERSION,
97+
},
98+
},
99+
response: {
100+
status_code: 200,
101+
},
102+
},
103+
spans: [],
104+
start_timestamp: 1733821670,
105+
timestamp: 1733821672,
106+
transaction: 'test',
107+
type: 'transaction',
108+
transaction_info: { source: 'custom' },
109+
});
110+
});
111+
});

0 commit comments

Comments
 (0)