Skip to content

Commit c2b0d12

Browse files
committed
feat(aws): Add support for streaming handlers
1 parent dbdd296 commit c2b0d12

File tree

6 files changed

+546
-202
lines changed

6 files changed

+546
-202
lines changed

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { AwsLambdaInstrumentation } from '@opentelemetry/instrumentation-aws-lambda';
22
import type { IntegrationFn } from '@sentry/core';
33
import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
4-
import { generateInstrumentOnce } from '@sentry/node';
5-
import { eventContextExtractor } from '../utils';
4+
import { captureException, generateInstrumentOnce } from '@sentry/node';
5+
import { eventContextExtractor, markEventUnhandled } from '../utils';
66

77
interface AwsLambdaOptions {
88
/**
@@ -27,6 +27,11 @@ export const instrumentAwsLambda = generateInstrumentOnce(
2727
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.otel.aws-lambda');
2828
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'function.aws.lambda');
2929
},
30+
responseHook(_span, { err }) {
31+
if (err) {
32+
captureException(err, scope => markEventUnhandled(scope, 'auto.function.aws-serverless.otel'));
33+
}
34+
},
3035
};
3136
},
3237
);

packages/aws-serverless/src/sdk.ts

Lines changed: 6 additions & 200 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,16 @@
1-
import type { Integration, Options, Scope } from '@sentry/core';
2-
import { applySdkMetadata, consoleSandbox, debug, getSDKSource } from '@sentry/core';
1+
import type { Integration, Options } from '@sentry/core';
2+
import { applySdkMetadata, debug, getSDKSource } from '@sentry/core';
33
import type { NodeClient, NodeOptions } from '@sentry/node';
4-
import {
5-
captureException,
6-
captureMessage,
7-
flush,
8-
getCurrentScope,
9-
getDefaultIntegrationsWithoutPerformance,
10-
initWithoutDefaultIntegrations,
11-
withScope,
12-
} from '@sentry/node';
13-
import type { Context, Handler } from 'aws-lambda';
4+
import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node';
5+
import type { Handler } from 'aws-lambda';
146
import { existsSync } from 'fs';
157
import { basename, resolve } from 'path';
16-
import { performance } from 'perf_hooks';
17-
import { types } from 'util';
188
import { DEBUG_BUILD } from './debug-build';
199
import { awsIntegration } from './integration/aws';
2010
import { awsLambdaIntegration } from './integration/awslambda';
21-
import { markEventUnhandled } from './utils';
11+
import { wrapHandler } from './wrappers/wrap-handler';
2212

23-
const { isPromise } = types;
24-
25-
// https://www.npmjs.com/package/aws-lambda-consumer
26-
type SyncHandler<T extends Handler> = (
27-
event: Parameters<T>[0],
28-
context: Parameters<T>[1],
29-
callback: Parameters<T>[2],
30-
) => void;
13+
export { wrapHandler };
3114

3215
export type AsyncHandler<T extends Handler> = (
3316
event: Parameters<T>[0],
@@ -89,25 +72,6 @@ function tryRequire<T>(taskRoot: string, subdir: string, mod: string): T {
8972
return require(require.resolve(mod, { paths: [taskRoot, subdir] }));
9073
}
9174

92-
/** */
93-
function isPromiseAllSettledResult<T>(result: T[]): boolean {
94-
return result.every(
95-
v =>
96-
Object.prototype.hasOwnProperty.call(v, 'status') &&
97-
(Object.prototype.hasOwnProperty.call(v, 'value') || Object.prototype.hasOwnProperty.call(v, 'reason')),
98-
);
99-
}
100-
101-
type PromiseSettledResult<T> = { status: 'rejected' | 'fulfilled'; reason?: T };
102-
103-
/** */
104-
function getRejectedReasons<T>(results: PromiseSettledResult<T>[]): T[] {
105-
return results.reduce((rejected: T[], result) => {
106-
if (result.status === 'rejected' && result.reason) rejected.push(result.reason);
107-
return rejected;
108-
}, []);
109-
}
110-
11175
/** */
11276
export function tryPatchHandler(taskRoot: string, handlerPath: string): void {
11377
type HandlerBag = HandlerModule | Handler | null | undefined;
@@ -159,161 +123,3 @@ export function tryPatchHandler(taskRoot: string, handlerPath: string): void {
159123
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
160124
(mod as HandlerModule)[functionName!] = wrapHandler(obj);
161125
}
162-
163-
/**
164-
* Tries to invoke context.getRemainingTimeInMillis if not available returns 0
165-
* Some environments use AWS lambda but don't support this function
166-
* @param context
167-
*/
168-
function tryGetRemainingTimeInMillis(context: Context): number {
169-
return typeof context.getRemainingTimeInMillis === 'function' ? context.getRemainingTimeInMillis() : 0;
170-
}
171-
172-
/**
173-
* Adds additional information from the environment and AWS Context to the Sentry Scope.
174-
*
175-
* @param scope Scope that should be enhanced
176-
* @param context AWS Lambda context that will be used to extract some part of the data
177-
* @param startTime performance.now() when wrapHandler was invoked
178-
*/
179-
function enhanceScopeWithEnvironmentData(scope: Scope, context: Context, startTime: number): void {
180-
scope.setContext('aws.lambda', {
181-
aws_request_id: context.awsRequestId,
182-
function_name: context.functionName,
183-
function_version: context.functionVersion,
184-
invoked_function_arn: context.invokedFunctionArn,
185-
execution_duration_in_millis: performance.now() - startTime,
186-
remaining_time_in_millis: tryGetRemainingTimeInMillis(context),
187-
'sys.argv': process.argv,
188-
});
189-
190-
scope.setContext('aws.cloudwatch.logs', {
191-
log_group: context.logGroupName,
192-
log_stream: context.logStreamName,
193-
url: `https://console.aws.amazon.com/cloudwatch/home?region=${
194-
process.env.AWS_REGION
195-
}#logsV2:log-groups/log-group/${encodeURIComponent(context.logGroupName)}/log-events/${encodeURIComponent(
196-
context.logStreamName,
197-
)}?filterPattern="${context.awsRequestId}"`,
198-
});
199-
}
200-
201-
/**
202-
* Wraps a lambda handler adding it error capture and tracing capabilities.
203-
*
204-
* @param handler Handler
205-
* @param options Options
206-
* @returns Handler
207-
*/
208-
export function wrapHandler<TEvent, TResult>(
209-
handler: Handler<TEvent, TResult>,
210-
wrapOptions: Partial<WrapperOptions> = {},
211-
): Handler<TEvent, TResult> {
212-
const START_TIME = performance.now();
213-
214-
// eslint-disable-next-line deprecation/deprecation
215-
if (typeof wrapOptions.startTrace !== 'undefined') {
216-
consoleSandbox(() => {
217-
// eslint-disable-next-line no-console
218-
console.warn(
219-
'The `startTrace` option is deprecated and will be removed in a future major version. If you want to disable tracing, set `SENTRY_TRACES_SAMPLE_RATE` to `0.0`.',
220-
);
221-
});
222-
}
223-
224-
const options: WrapperOptions = {
225-
flushTimeout: 2000,
226-
callbackWaitsForEmptyEventLoop: false,
227-
captureTimeoutWarning: true,
228-
timeoutWarningLimit: 500,
229-
captureAllSettledReasons: false,
230-
startTrace: true, // TODO(v11): Remove this option. Set to true here to satisfy the type, but has no effect.
231-
...wrapOptions,
232-
};
233-
234-
let timeoutWarningTimer: NodeJS.Timeout;
235-
236-
// AWSLambda is like Express. It makes a distinction about handlers based on its last argument
237-
// async (event) => async handler
238-
// async (event, context) => async handler
239-
// (event, context, callback) => sync handler
240-
// Nevertheless whatever option is chosen by user, we convert it to async handler.
241-
const asyncHandler: AsyncHandler<typeof handler> =
242-
handler.length > 2
243-
? (event, context) =>
244-
new Promise((resolve, reject) => {
245-
const rv = (handler as SyncHandler<typeof handler>)(event, context, (error, result) => {
246-
if (error === null || error === undefined) {
247-
resolve(result!); // eslint-disable-line @typescript-eslint/no-non-null-assertion
248-
} else {
249-
reject(error);
250-
}
251-
}) as unknown;
252-
253-
// This should never happen, but still can if someone writes a handler as
254-
// `async (event, context, callback) => {}`
255-
if (isPromise(rv)) {
256-
void (rv as Promise<NonNullable<TResult>>).then(resolve, reject);
257-
}
258-
})
259-
: (handler as AsyncHandler<typeof handler>);
260-
261-
return async (event, context) => {
262-
context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop;
263-
264-
// In seconds. You cannot go any more granular than this in AWS Lambda.
265-
const configuredTimeout = Math.ceil(tryGetRemainingTimeInMillis(context) / 1000);
266-
const configuredTimeoutMinutes = Math.floor(configuredTimeout / 60);
267-
const configuredTimeoutSeconds = configuredTimeout % 60;
268-
269-
const humanReadableTimeout =
270-
configuredTimeoutMinutes > 0
271-
? `${configuredTimeoutMinutes}m${configuredTimeoutSeconds}s`
272-
: `${configuredTimeoutSeconds}s`;
273-
274-
// When `callbackWaitsForEmptyEventLoop` is set to false, which it should when using `captureTimeoutWarning`,
275-
// we don't have a guarantee that this message will be delivered. Because of that, we don't flush it.
276-
if (options.captureTimeoutWarning) {
277-
const timeoutWarningDelay = tryGetRemainingTimeInMillis(context) - options.timeoutWarningLimit;
278-
279-
timeoutWarningTimer = setTimeout(() => {
280-
withScope(scope => {
281-
scope.setTag('timeout', humanReadableTimeout);
282-
captureMessage(`Possible function timeout: ${context.functionName}`, 'warning');
283-
});
284-
}, timeoutWarningDelay) as unknown as NodeJS.Timeout;
285-
}
286-
287-
async function processResult(): Promise<TResult> {
288-
const scope = getCurrentScope();
289-
290-
let rv: TResult;
291-
try {
292-
enhanceScopeWithEnvironmentData(scope, context, START_TIME);
293-
294-
rv = await asyncHandler(event, context);
295-
296-
// We manage lambdas that use Promise.allSettled by capturing the errors of failed promises
297-
if (options.captureAllSettledReasons && Array.isArray(rv) && isPromiseAllSettledResult(rv)) {
298-
const reasons = getRejectedReasons(rv);
299-
reasons.forEach(exception => {
300-
captureException(exception, scope => markEventUnhandled(scope, 'auto.function.aws-serverless.promise'));
301-
});
302-
}
303-
} catch (e) {
304-
captureException(e, scope => markEventUnhandled(scope, 'auto.function.aws-serverless.handler'));
305-
throw e;
306-
} finally {
307-
clearTimeout(timeoutWarningTimer);
308-
await flush(options.flushTimeout).catch(e => {
309-
DEBUG_BUILD && debug.error(e);
310-
});
311-
}
312-
return rv;
313-
}
314-
315-
return withScope(async () => {
316-
return processResult();
317-
});
318-
};
319-
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { Scope } from '@sentry/core';
2+
import { captureMessage, withScope } from '@sentry/node';
3+
import type { Context } from 'aws-lambda';
4+
5+
export interface WrapperOptions {
6+
flushTimeout: number;
7+
callbackWaitsForEmptyEventLoop: boolean;
8+
captureTimeoutWarning: boolean;
9+
timeoutWarningLimit: number;
10+
/**
11+
* Capture all errors when `Promise.allSettled` is returned by the handler
12+
* The {@link wrapHandler} will not fail the lambda even if there are errors
13+
* @default false
14+
*/
15+
captureAllSettledReasons: boolean;
16+
// TODO(v11): Remove this option since its no longer used.
17+
/**
18+
* @deprecated This option has no effect and will be removed in a future major version.
19+
* If you want to disable tracing, set `SENTRY_TRACES_SAMPLE_RATE` to `0.0`, otherwise OpenTelemetry will automatically trace the handler.
20+
*/
21+
startTrace: boolean;
22+
}
23+
24+
export const AWS_HANDLER_HIGHWATERMARK = Symbol.for('aws.lambda.runtime.handler.highWaterMark');
25+
export const AWS_HANDLER_STREAMING = Symbol.for('aws.lambda.runtime.handler.streaming');
26+
export const AWS_STREAM_RESPONSE = 'response';
27+
28+
/**
29+
*
30+
*/
31+
export function createDefaultWrapperOptions(wrapOptions: Partial<WrapperOptions> = {}): WrapperOptions {
32+
return {
33+
flushTimeout: 2000,
34+
callbackWaitsForEmptyEventLoop: false,
35+
captureTimeoutWarning: true,
36+
timeoutWarningLimit: 500,
37+
captureAllSettledReasons: false,
38+
startTrace: true, // TODO(v11): Remove this option. Set to true here to satisfy the type, but has no effect.
39+
...wrapOptions,
40+
};
41+
}
42+
43+
/**
44+
*
45+
*/
46+
export function setupTimeoutWarning(context: Context, options: WrapperOptions): NodeJS.Timeout | undefined {
47+
if (!options.captureTimeoutWarning) {
48+
return undefined;
49+
}
50+
51+
const timeoutWarningDelay = tryGetRemainingTimeInMillis(context) - options.timeoutWarningLimit;
52+
const humanReadableTimeout = getHumanReadableTimeout(context);
53+
54+
return setTimeout(() => {
55+
withScope(scope => {
56+
scope.setTag('timeout', humanReadableTimeout);
57+
captureMessage(`Possible function timeout: ${context.functionName}`, 'warning');
58+
});
59+
}, timeoutWarningDelay) as unknown as NodeJS.Timeout;
60+
}
61+
62+
/**
63+
* Adds additional information from the environment and AWS Context to the Sentry Scope.
64+
*
65+
* @param scope Scope that should be enhanced
66+
* @param context AWS Lambda context that will be used to extract some part of the data
67+
* @param startTime performance.now() when wrapHandler was invoked
68+
*/
69+
export function enhanceScopeWithEnvironmentData(scope: Scope, context: Context, startTime: number): void {
70+
scope.setContext('aws.lambda', {
71+
aws_request_id: context.awsRequestId,
72+
function_name: context.functionName,
73+
function_version: context.functionVersion,
74+
invoked_function_arn: context.invokedFunctionArn,
75+
execution_duration_in_millis: performance.now() - startTime,
76+
remaining_time_in_millis: tryGetRemainingTimeInMillis(context),
77+
'sys.argv': process.argv,
78+
});
79+
80+
scope.setContext('aws.cloudwatch.logs', {
81+
log_group: context.logGroupName,
82+
log_stream: context.logStreamName,
83+
url: `https://console.aws.amazon.com/cloudwatch/home?region=${
84+
process.env.AWS_REGION
85+
}#logsV2:log-groups/log-group/${encodeURIComponent(context.logGroupName)}/log-events/${encodeURIComponent(
86+
context.logStreamName,
87+
)}?filterPattern="${context.awsRequestId}"`,
88+
});
89+
}
90+
91+
/**
92+
*
93+
*/
94+
export function getHumanReadableTimeout(context: Context): string {
95+
const configuredTimeout = Math.ceil(tryGetRemainingTimeInMillis(context) / 1000);
96+
const configuredTimeoutMinutes = Math.floor(configuredTimeout / 60);
97+
const configuredTimeoutSeconds = configuredTimeout % 60;
98+
99+
return configuredTimeoutMinutes > 0
100+
? `${configuredTimeoutMinutes}m${configuredTimeoutSeconds}s`
101+
: `${configuredTimeoutSeconds}s`;
102+
}
103+
104+
/**
105+
* Tries to invoke context.getRemainingTimeInMillis if not available returns 0
106+
* Some environments use AWS lambda but don't support this function
107+
* @param context
108+
*/
109+
function tryGetRemainingTimeInMillis(context: Context): number {
110+
return typeof context.getRemainingTimeInMillis === 'function' ? context.getRemainingTimeInMillis() : 0;
111+
}

0 commit comments

Comments
 (0)