Skip to content

Commit

Permalink
feat(opentelemetry): Set response context for http.server spans (#1…
Browse files Browse the repository at this point in the history
…4634)

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
  • Loading branch information
mydea authored Dec 11, 2024
1 parent 71a5a7b commit 3339829
Show file tree
Hide file tree
Showing 5 changed files with 201 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ test('Sends an API route transaction', async ({ baseURL }) => {
origin: 'auto.http.otel.http',
});

expect(transactionEvent.contexts?.response).toEqual({
status_code: 200,
});

expect(transactionEvent).toEqual(
expect.objectContaining({
spans: expect.arrayContaining([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ test('Sends an API route transaction', async ({ baseURL }) => {
origin: 'auto.http.otel.http',
});

expect(transactionEvent.contexts?.response).toEqual({
status_code: 200,
});

expect(transactionEvent).toEqual(
expect.objectContaining({
transaction: 'GET /test-transaction',
Expand Down
88 changes: 64 additions & 24 deletions packages/core/test/lib/tracing/sentrySpan.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCurrentClient, timestampInSeconds } from '../../../src';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getCurrentScope, setCurrentClient, timestampInSeconds } from '../../../src';
import { SentrySpan } from '../../../src/tracing/sentrySpan';
import { SPAN_STATUS_ERROR } from '../../../src/tracing/spanstatus';
import { TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED, spanToJSON } from '../../../src/utils/spanUtils';
Expand Down Expand Up @@ -95,14 +95,30 @@ describe('SentrySpan', () => {
});
});

describe('finish', () => {
describe('end', () => {
test('simple', () => {
const span = new SentrySpan({});
expect(spanToJSON(span).timestamp).toBeUndefined();
span.end();
expect(spanToJSON(span).timestamp).toBeGreaterThan(1);
});

test('with endTime in seconds', () => {
const span = new SentrySpan({});
expect(spanToJSON(span).timestamp).toBeUndefined();
const endTime = Date.now() / 1000;
span.end(endTime);
expect(spanToJSON(span).timestamp).toBe(endTime);
});

test('with endTime in milliseconds', () => {
const span = new SentrySpan({});
expect(spanToJSON(span).timestamp).toBeUndefined();
const endTime = Date.now();
span.end(endTime);
expect(spanToJSON(span).timestamp).toBe(endTime / 1000);
});

test('uses sampled config for standalone span', () => {
const client = new TestClient(
getDefaultTestClientOptions({
Expand Down Expand Up @@ -136,7 +152,7 @@ describe('SentrySpan', () => {
expect(mockSend).toHaveBeenCalledTimes(1);
});

test('sends the span if `beforeSendSpan` does not modify the span ', () => {
test('sends the span if `beforeSendSpan` does not modify the span', () => {
const beforeSendSpan = jest.fn(span => span);
const client = new TestClient(
getDefaultTestClientOptions({
Expand Down Expand Up @@ -194,30 +210,54 @@ describe('SentrySpan', () => {
);
consoleWarnSpy.mockRestore();
});
});

describe('end', () => {
test('simple', () => {
const span = new SentrySpan({});
expect(spanToJSON(span).timestamp).toBeUndefined();
span.end();
expect(spanToJSON(span).timestamp).toBeGreaterThan(1);
});
test('build TransactionEvent for basic root span', () => {
const client = new TestClient(
getDefaultTestClientOptions({
dsn: 'https://username@domain/123',
}),
);
setCurrentClient(client);

test('with endTime in seconds', () => {
const span = new SentrySpan({});
expect(spanToJSON(span).timestamp).toBeUndefined();
const endTime = Date.now() / 1000;
span.end(endTime);
expect(spanToJSON(span).timestamp).toBe(endTime);
});
const scope = getCurrentScope();
const captureEventSpy = jest.spyOn(scope, 'captureEvent').mockImplementation(() => 'testId');

test('with endTime in milliseconds', () => {
const span = new SentrySpan({});
expect(spanToJSON(span).timestamp).toBeUndefined();
const endTime = Date.now();
span.end(endTime);
expect(spanToJSON(span).timestamp).toBe(endTime / 1000);
const span = new SentrySpan({
name: 'test',
startTimestamp: 1,
sampled: true,
});
span.end(2);

expect(captureEventSpy).toHaveBeenCalledTimes(1);
expect(captureEventSpy).toHaveBeenCalledWith({
_metrics_summary: undefined,
contexts: {
trace: {
data: {
'sentry.origin': 'manual',
},
origin: 'manual',
span_id: expect.stringMatching(/^[a-f0-9]{16}$/),
trace_id: expect.stringMatching(/^[a-f0-9]{32}$/),
},
},
sdkProcessingMetadata: {
capturedSpanIsolationScope: undefined,
capturedSpanScope: undefined,
dynamicSamplingContext: {
environment: 'production',
public_key: 'username',
trace_id: expect.stringMatching(/^[a-f0-9]{32}$/),
transaction: 'test',
},
},
spans: [],
start_timestamp: 1,
timestamp: 2,
transaction: 'test',
type: 'transaction',
});
});
});

Expand Down
23 changes: 18 additions & 5 deletions packages/opentelemetry/src/spanExporter.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
/* eslint-disable max-lines */
import type { Span } from '@opentelemetry/api';
import { SpanKind } from '@opentelemetry/api';
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
import { ATTR_HTTP_RESPONSE_STATUS_CODE, SEMATTRS_HTTP_STATUS_CODE } from '@opentelemetry/semantic-conventions';
import type { SpanJSON, SpanOrigin, TraceContext, TransactionEvent, TransactionSource } from '@sentry/core';
import type {
SpanAttributes,
SpanJSON,
SpanOrigin,
TraceContext,
TransactionEvent,
TransactionSource,
} from '@sentry/core';
import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand Down Expand Up @@ -221,13 +229,14 @@ function parseSpan(span: ReadableSpan): { op?: string; origin?: SpanOrigin; sour
return { origin, op, source };
}

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

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

const attributes = dropUndefinedKeys({
const attributes: SpanAttributes = dropUndefinedKeys({
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: sampleRate,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
Expand Down Expand Up @@ -257,12 +266,16 @@ function createTransactionForOtelSpan(span: ReadableSpan): TransactionEvent {
status: getStatusMessage(status), // As per protocol, span status is allowed to be undefined
});

const transactionEvent: TransactionEvent = {
const statusCode = attributes[ATTR_HTTP_RESPONSE_STATUS_CODE];
const responseContext = typeof statusCode === 'number' ? { response: { status_code: statusCode } } : undefined;

const transactionEvent: TransactionEvent = dropUndefinedKeys({
contexts: {
trace: traceContext,
otel: {
resource: span.resource.attributes,
},
...responseContext,
},
spans: [],
start_timestamp: spanTimeInputToSeconds(span.startTime),
Expand All @@ -283,7 +296,7 @@ function createTransactionForOtelSpan(span: ReadableSpan): TransactionEvent {
},
}),
_metrics_summary: getMetricSummaryJsonForSpan(span as unknown as Span),
};
});

return transactionEvent;
}
Expand Down
111 changes: 111 additions & 0 deletions packages/opentelemetry/test/spanExporter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { ATTR_HTTP_RESPONSE_STATUS_CODE } from '@opentelemetry/semantic-conventions';
import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, startInactiveSpan } from '@sentry/core';
import { createTransactionForOtelSpan } from '../src/spanExporter';
import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit';

describe('createTransactionForOtelSpan', () => {
beforeEach(() => {
mockSdkInit({
enableTracing: true,
});
});

afterEach(() => {
cleanupOtel();
});

it('works with a basic span', () => {
const span = startInactiveSpan({ name: 'test', startTime: 1733821670000 });
span.end(1733821672000);

const event = createTransactionForOtelSpan(span as any);
// we do not care about this here
delete event.sdkProcessingMetadata;

expect(event).toEqual({
contexts: {
trace: {
span_id: expect.stringMatching(/[a-f0-9]{16}/),
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
data: {
'sentry.source': 'custom',
'sentry.sample_rate': 1,
'sentry.origin': 'manual',
},
origin: 'manual',
status: 'ok',
},
otel: {
resource: {
'service.name': 'opentelemetry-test',
'telemetry.sdk.language': 'nodejs',
'telemetry.sdk.name': 'opentelemetry',
'telemetry.sdk.version': expect.any(String),
'service.namespace': 'sentry',
'service.version': SDK_VERSION,
},
},
},
spans: [],
start_timestamp: 1733821670,
timestamp: 1733821672,
transaction: 'test',
type: 'transaction',
transaction_info: { source: 'custom' },
});
});

it('works with a http.server span', () => {
const span = startInactiveSpan({
name: 'test',
startTime: 1733821670000,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
[ATTR_HTTP_RESPONSE_STATUS_CODE]: 200,
},
});
span.end(1733821672000);

const event = createTransactionForOtelSpan(span as any);
// we do not care about this here
delete event.sdkProcessingMetadata;

expect(event).toEqual({
contexts: {
trace: {
span_id: expect.stringMatching(/[a-f0-9]{16}/),
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
data: {
'sentry.source': 'custom',
'sentry.sample_rate': 1,
'sentry.origin': 'manual',
'sentry.op': 'http.server',
'http.response.status_code': 200,
},
origin: 'manual',
status: 'ok',
op: 'http.server',
},
otel: {
resource: {
'service.name': 'opentelemetry-test',
'telemetry.sdk.language': 'nodejs',
'telemetry.sdk.name': 'opentelemetry',
'telemetry.sdk.version': expect.any(String),
'service.namespace': 'sentry',
'service.version': SDK_VERSION,
},
},
response: {
status_code: 200,
},
},
spans: [],
start_timestamp: 1733821670,
timestamp: 1733821672,
transaction: 'test',
type: 'transaction',
transaction_info: { source: 'custom' },
});
});
});

0 comments on commit 3339829

Please sign in to comment.