Skip to content

Commit b17ac59

Browse files
authored
fix(aws-serverless): Extract sentry trace data from handler context over event (#13266)
Currently, the AWS otel integration (and our `wrapHandler` fallback) try to extract sentry trace data from the `event` object passed to a Lambda call. The aws-sdk integration, however, places tracing data onto `context.clientContext.Custom`. This PR adds a custom `eventContextExtractor` that attempts extracting sentry trace data from the `context`, with a fallback to `event` to enable distributed tracing among Lambda invocations. Traces are now connected. Here an example: `Lambda-A` calling `Lambda-B`: ``` import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda"; import * as Sentry from "@sentry/aws-serverless"; export const handler = Sentry.wrapHandler(async (event, context) => { const client = new LambdaClient(); const command = new InvokeCommand({ FunctionName: `Lambda-B`, InvocationType: "RequestResponse", Payload: new Uint16Array(), }) return client.send(command); }); ``` `Lambda-B`: ``` import * as Sentry from "@sentry/aws-serverless"; Sentry.addIntegration(Sentry.postgresIntegration()) export const handler = Sentry.wrapHandler(async (event) => { const queryString = "select count(*) from myTable;"; return await Sentry.startSpan({ name: queryString, op: "db.sql.execute" }, async (span) => { console.log('executing query', queryString); }) }) ``` ![CleanShot 2024-08-07 at 16 34 51@2x](https://github.com/user-attachments/assets/43f5dd9e-e5af-4667-9551-05fac90f03a6) Closes: #13146
1 parent 6cbc416 commit b17ac59

File tree

5 files changed

+217
-21
lines changed

5 files changed

+217
-21
lines changed

packages/aws-serverless/rollup.npm.config.mjs

+4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export default [
99
entrypoints: ['src/index.ts', 'src/awslambda-auto.ts'],
1010
// packages with bundles have a different build directory structure
1111
hasBundles: true,
12+
packageSpecificConfig: {
13+
// Used for our custom eventContextExtractor
14+
external: ['@opentelemetry/api'],
15+
},
1216
}),
1317
),
1418
...makeOtelLoaders('./build', 'sentry-node'),

packages/aws-serverless/src/integration/awslambda.ts

+34-10
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,44 @@
11
import { AwsLambdaInstrumentation } from '@opentelemetry/instrumentation-aws-lambda';
22
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, defineIntegration } from '@sentry/core';
3-
import { addOpenTelemetryInstrumentation } from '@sentry/node';
3+
import { generateInstrumentOnce } from '@sentry/node';
44
import type { IntegrationFn } from '@sentry/types';
5+
import { eventContextExtractor } from '../utils';
56

6-
const _awsLambdaIntegration = (() => {
7+
interface AwsLambdaOptions {
8+
/**
9+
* Disables the AWS context propagation and instead uses
10+
* Sentry's context. Defaults to `true`, in order for
11+
* Sentry trace propagation to take precedence, but can
12+
* be disabled if you want AWS propagation to take take
13+
* precedence.
14+
*/
15+
disableAwsContextPropagation?: boolean;
16+
}
17+
18+
export const instrumentAwsLambda = generateInstrumentOnce<AwsLambdaOptions>(
19+
'AwsLambda',
20+
(_options: AwsLambdaOptions = {}) => {
21+
const options = {
22+
disableAwsContextPropagation: true,
23+
..._options,
24+
};
25+
26+
return new AwsLambdaInstrumentation({
27+
...options,
28+
eventContextExtractor,
29+
requestHook(span) {
30+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.otel.aws-lambda');
31+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'function.aws.lambda');
32+
},
33+
});
34+
},
35+
);
36+
37+
const _awsLambdaIntegration = ((options: AwsLambdaOptions = {}) => {
738
return {
839
name: 'AwsLambda',
940
setupOnce() {
10-
addOpenTelemetryInstrumentation(
11-
new AwsLambdaInstrumentation({
12-
requestHook(span) {
13-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.otel.aws-lambda');
14-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'function.aws.lambda');
15-
},
16-
}),
17-
);
41+
instrumentAwsLambda(options);
1842
},
1943
};
2044
}) satisfies IntegrationFn;

packages/aws-serverless/src/sdk.ts

+4-10
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
withScope,
1717
} from '@sentry/node';
1818
import type { Integration, Options, Scope, SdkMetadata, Span } from '@sentry/types';
19-
import { isString, logger } from '@sentry/utils';
19+
import { logger } from '@sentry/utils';
2020
import type { Context, Handler } from 'aws-lambda';
2121
import { performance } from 'perf_hooks';
2222

@@ -25,7 +25,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } fr
2525
import { DEBUG_BUILD } from './debug-build';
2626
import { awsIntegration } from './integration/aws';
2727
import { awsLambdaIntegration } from './integration/awslambda';
28-
import { markEventUnhandled } from './utils';
28+
import { getAwsTraceData, markEventUnhandled } from './utils';
2929

3030
const { isPromise } = types;
3131

@@ -334,15 +334,9 @@ export function wrapHandler<TEvent, TResult>(
334334
// Otherwise, we create two root spans (one from otel, one from our wrapper).
335335
// If Otel instrumentation didn't work or was filtered by users, we still want to trace the handler.
336336
if (options.startTrace && !isWrappedByOtel(handler)) {
337-
const eventWithHeaders = event as { headers?: { [key: string]: string } };
337+
const traceData = getAwsTraceData(event as { headers?: Record<string, string> }, context);
338338

339-
const sentryTrace =
340-
eventWithHeaders.headers && isString(eventWithHeaders.headers['sentry-trace'])
341-
? eventWithHeaders.headers['sentry-trace']
342-
: undefined;
343-
const baggage = eventWithHeaders.headers?.baggage;
344-
345-
return continueTrace({ sentryTrace, baggage }, () => {
339+
return continueTrace({ sentryTrace: traceData['sentry-trace'], baggage: traceData.baggage }, () => {
346340
return startSpanManual(
347341
{
348342
name: context.functionName,

packages/aws-serverless/src/utils.ts

+73-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
1+
import type { TextMapGetter } from '@opentelemetry/api';
2+
import type { Context as OtelContext } from '@opentelemetry/api';
3+
import { context as otelContext, propagation } from '@opentelemetry/api';
14
import type { Scope } from '@sentry/types';
2-
import { addExceptionMechanism } from '@sentry/utils';
5+
import { addExceptionMechanism, isString } from '@sentry/utils';
6+
import type { Handler } from 'aws-lambda';
7+
import type { APIGatewayProxyEventHeaders } from 'aws-lambda';
8+
9+
type HandlerEvent = Parameters<Handler<{ headers?: Record<string, string> }>>[0];
10+
type HandlerContext = Parameters<Handler>[1];
11+
12+
type TraceData = {
13+
'sentry-trace'?: string;
14+
baggage?: string;
15+
};
16+
17+
// vendored from
18+
// https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/plugins/node/opentelemetry-instrumentation-aws-lambda/src/instrumentation.ts#L65-L72
19+
const headerGetter: TextMapGetter<APIGatewayProxyEventHeaders> = {
20+
keys(carrier): string[] {
21+
return Object.keys(carrier);
22+
},
23+
get(carrier, key: string) {
24+
return carrier[key];
25+
},
26+
};
327

428
/**
529
* Marks an event as unhandled by adding a span processor to the passed scope.
@@ -12,3 +36,51 @@ export function markEventUnhandled(scope: Scope): Scope {
1236

1337
return scope;
1438
}
39+
40+
/**
41+
* Extracts sentry trace data from the handler `context` if available and falls
42+
* back to the `event`.
43+
*
44+
* When instrumenting the Lambda function with Sentry, the sentry trace data
45+
* is placed on `context.clientContext.Custom`. Users are free to modify context
46+
* tho and provide this data via `event` or `context`.
47+
*/
48+
export function getAwsTraceData(event: HandlerEvent, context?: HandlerContext): TraceData {
49+
const headers = event.headers || {};
50+
51+
const traceData: TraceData = {
52+
'sentry-trace': headers['sentry-trace'],
53+
baggage: headers.baggage,
54+
};
55+
56+
if (context && context.clientContext && context.clientContext.Custom) {
57+
const customContext: Record<string, unknown> = context.clientContext.Custom;
58+
const sentryTrace = isString(customContext['sentry-trace']) ? customContext['sentry-trace'] : undefined;
59+
60+
if (sentryTrace) {
61+
traceData['sentry-trace'] = sentryTrace;
62+
traceData.baggage = isString(customContext.baggage) ? customContext.baggage : undefined;
63+
}
64+
}
65+
66+
return traceData;
67+
}
68+
69+
/**
70+
* A custom event context extractor for the aws integration. It takes sentry trace data
71+
* from the context rather than the event, with the event being a fallback.
72+
*
73+
* Is only used when the handler was successfully wrapped by otel and the integration option
74+
* `disableAwsContextPropagation` is `true`.
75+
*/
76+
export function eventContextExtractor(event: HandlerEvent, context?: HandlerContext): OtelContext {
77+
// The default context extractor tries to get sampled trace headers from HTTP headers
78+
// The otel aws integration packs these onto the context, so we try to extract them from
79+
// there instead.
80+
const httpHeaders = {
81+
...(event.headers || {}),
82+
...getAwsTraceData(event, context),
83+
};
84+
85+
return propagation.extract(otelContext.active(), httpHeaders, headerGetter);
86+
}
+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { eventContextExtractor, getAwsTraceData } from '../src/utils';
2+
3+
const mockExtractContext = jest.fn();
4+
jest.mock('@opentelemetry/api', () => {
5+
const actualApi = jest.requireActual('@opentelemetry/api');
6+
return {
7+
...actualApi,
8+
propagation: {
9+
extract: (...args: unknown[]) => mockExtractContext(args),
10+
},
11+
};
12+
});
13+
14+
const mockContext = {
15+
clientContext: {
16+
Custom: {
17+
'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',
18+
baggage: 'sentry-environment=production',
19+
},
20+
},
21+
};
22+
const mockEvent = {
23+
headers: {
24+
'sentry-trace': '12345678901234567890123456789012-1234567890123456-2',
25+
baggage: 'sentry-environment=staging',
26+
},
27+
};
28+
29+
describe('getTraceData', () => {
30+
test('gets sentry trace data from the context', () => {
31+
// @ts-expect-error, a partial context object is fine here
32+
const traceData = getAwsTraceData({}, mockContext);
33+
34+
expect(traceData['sentry-trace']).toEqual('12345678901234567890123456789012-1234567890123456-1');
35+
expect(traceData.baggage).toEqual('sentry-environment=production');
36+
});
37+
38+
test('gets sentry trace data from the context even if event has data', () => {
39+
// @ts-expect-error, a partial context object is fine here
40+
const traceData = getAwsTraceData(mockEvent, mockContext);
41+
42+
expect(traceData['sentry-trace']).toEqual('12345678901234567890123456789012-1234567890123456-1');
43+
expect(traceData.baggage).toEqual('sentry-environment=production');
44+
});
45+
46+
test('gets sentry trace data from the event if no context is passed', () => {
47+
const traceData = getAwsTraceData(mockEvent);
48+
49+
expect(traceData['sentry-trace']).toEqual('12345678901234567890123456789012-1234567890123456-2');
50+
expect(traceData.baggage).toEqual('sentry-environment=staging');
51+
});
52+
53+
test('gets sentry trace data from the event if the context sentry trace is undefined', () => {
54+
const traceData = getAwsTraceData(mockEvent, {
55+
// @ts-expect-error, a partial context object is fine here
56+
clientContext: { Custom: { 'sentry-trace': undefined, baggage: '' } },
57+
});
58+
59+
expect(traceData['sentry-trace']).toEqual('12345678901234567890123456789012-1234567890123456-2');
60+
expect(traceData.baggage).toEqual('sentry-environment=staging');
61+
});
62+
});
63+
64+
describe('eventContextExtractor', () => {
65+
afterEach(() => {
66+
jest.clearAllMocks();
67+
});
68+
69+
test('passes sentry trace data to the propagation extractor', () => {
70+
// @ts-expect-error, a partial context object is fine here
71+
eventContextExtractor(mockEvent, mockContext);
72+
73+
// @ts-expect-error, a partial context object is fine here
74+
const expectedTraceData = getAwsTraceData(mockEvent, mockContext);
75+
76+
expect(mockExtractContext).toHaveBeenCalledTimes(1);
77+
expect(mockExtractContext).toHaveBeenCalledWith(expect.arrayContaining([expectedTraceData]));
78+
});
79+
80+
test('passes along non-sentry trace headers along', () => {
81+
eventContextExtractor(
82+
{
83+
...mockEvent,
84+
headers: {
85+
...mockEvent.headers,
86+
'X-Custom-Header': 'Foo',
87+
},
88+
},
89+
// @ts-expect-error, a partial context object is fine here
90+
mockContext,
91+
);
92+
93+
const expectedHeaders = {
94+
'X-Custom-Header': 'Foo',
95+
// @ts-expect-error, a partial context object is fine here
96+
...getAwsTraceData(mockEvent, mockContext),
97+
};
98+
99+
expect(mockExtractContext).toHaveBeenCalledTimes(1);
100+
expect(mockExtractContext).toHaveBeenCalledWith(expect.arrayContaining([expectedHeaders]));
101+
});
102+
});

0 commit comments

Comments
 (0)