Skip to content

feat(serverless/awslambda/captureAllSettledReasons) : Build specific context for each captured promises #6492

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
62 changes: 42 additions & 20 deletions packages/serverless/src/awslambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import * as Sentry from '@sentry/node';
import { captureException, captureMessage, flush, getCurrentHub, Scope, withScope } from '@sentry/node';
import { extractTraceparentData } from '@sentry/tracing';
import { Integration } from '@sentry/types';
import { CaptureContext, Integration } from '@sentry/types';
import { baggageHeaderToDynamicSamplingContext, dsnFromString, dsnToString, isString, logger } from '@sentry/utils';
// NOTE: I have no idea how to fix this right now, and don't want to waste more time, as it builds just fine — Kamil
// eslint-disable-next-line import/no-unresolved
Expand Down Expand Up @@ -44,7 +44,21 @@ export interface WrapperOptions {
* The {@link wrapHandler} will not fail the lambda even if there are errors
* @default false
*/
captureAllSettledReasons: boolean;
captureAllSettledReasons:
| boolean
| {
/**
* Build {@link CaptureContext} according to the error reported.
* @param event The original event.
* @param reason The `reason` property of the promise rejection
* @param allSettledResultIndex The {@link PromiseSettledResult[]} `index`
*/
contextBuilderFn?: (
event: Parameters<Handler>[0],
reason: PromiseSettledResult<unknown>['reason'],
allSettledResultIndex: number,
) => CaptureContext | Promise<CaptureContext>;
};
}

export const defaultIntegrations: Integration[] = [...Sentry.defaultIntegrations, new AWSServices({ optional: true })];
Expand Down Expand Up @@ -119,24 +133,24 @@ function tryRequire<T>(taskRoot: string, subdir: string, mod: string): T {
// Node-style path
return require(require.resolve(mod, { paths: [taskRoot, subdir] }));
}
type PromiseSettledResult<T, S extends 'rejected' | 'fulfilled' = 'rejected' | 'fulfilled'> = {
status: S;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
reason?: any;
value: T;
};

/** */
function isPromiseAllSettledResult<T>(result: T[]): boolean {
return result.every(
v =>
Object.prototype.hasOwnProperty.call(v, 'status') &&
(Object.prototype.hasOwnProperty.call(v, 'value') || Object.prototype.hasOwnProperty.call(v, 'reason')),
);
function isPromiseAllSettledResult<T>(result: unknown): result is PromiseSettledResult<T>[] {
return Array.isArray(result) && result.every(v => isPromiseSettledResult(v));
}

type PromiseSettledResult<T> = { status: 'rejected' | 'fulfilled'; reason?: T };

/** */
function getRejectedReasons<T>(results: PromiseSettledResult<T>[]): T[] {
return results.reduce((rejected: T[], result) => {
if (result.status === 'rejected' && result.reason) rejected.push(result.reason);
return rejected;
}, []);
function isPromiseSettledResult<T>(result: unknown): result is PromiseSettledResult<T> {
return (
Object.prototype.hasOwnProperty.call(result, 'status') &&
(Object.prototype.hasOwnProperty.call(result, 'value') || Object.prototype.hasOwnProperty.call(result, 'reason'))
);
}

/** */
Expand Down Expand Up @@ -332,11 +346,19 @@ export function wrapHandler<TEvent, TResult>(
rv = await asyncHandler(event, context);

// We manage lambdas that use Promise.allSettled by capturing the errors of failed promises
if (options.captureAllSettledReasons && Array.isArray(rv) && isPromiseAllSettledResult(rv)) {
const reasons = getRejectedReasons(rv);
reasons.forEach(exception => {
captureException(exception);
});
if (options.captureAllSettledReasons && isPromiseAllSettledResult(rv)) {
await Promise.all(
rv.map(async (result, allSettledIndex) => {
if (result.status === 'rejected') {
const contextPromise = Promise.resolve(
typeof options.captureAllSettledReasons !== 'boolean'
? options.captureAllSettledReasons.contextBuilderFn?.(event, result.reason, allSettledIndex)
: undefined,
);
captureException(result.reason, await contextPromise);
}
}),
);
}
} catch (e) {
captureException(e);
Expand Down
64 changes: 49 additions & 15 deletions packages/serverless/test/awslambda.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Callback, Handler } from 'aws-lambda';

import * as Sentry from '../src';
import { WrapperOptions } from '../src/awslambda';

const { wrapHandler } = Sentry.AWSLambda;

Expand Down Expand Up @@ -155,27 +156,60 @@ describe('AWSLambda', () => {
expect(Sentry.fakeScope.setTag).toBeCalledWith('timeout', '1m40s');
});

test('captureAllSettledReasons disabled (default)', async () => {
const handler = () => Promise.resolve([{ status: 'rejected', reason: new Error() }]);
const wrappedHandler = wrapHandler(handler, { flushTimeout: 1337 });
await wrappedHandler(fakeEvent, fakeContext, fakeCallback);
expect(Sentry.captureException).toBeCalledTimes(0);
});

test('captureAllSettledReasons enable', async () => {
const error = new Error();
const error2 = new Error();
describe('captureAllSettledReasons', () => {
const error = new Error('error1');
const error2 = new Error('error2');
const handler = () =>
Promise.resolve([
{ status: 'rejected', reason: error },
{ status: 'fulfilled', value: undefined },
{ status: 'rejected', reason: error2 },
]);
const wrappedHandler = wrapHandler(handler, { flushTimeout: 1337, captureAllSettledReasons: true });
await wrappedHandler(fakeEvent, fakeContext, fakeCallback);
expect(Sentry.captureException).toHaveBeenNthCalledWith(1, error);
expect(Sentry.captureException).toHaveBeenNthCalledWith(2, error2);
expect(Sentry.captureException).toBeCalledTimes(2);

test.each([
['undefined', undefined],
['no options', {}],
['boolean false', { captureAllSettledReasons: false }],
['with other options', { flushTimeout: 1337 }],
])('default: captureAllSettledReasons is disable (%s)', async (_, config) => {
const wrappedHandler = wrapHandler(handler, config);
await wrappedHandler(fakeEvent, fakeContext, fakeCallback);
expect(Sentry.captureException).toBeCalledTimes(0);
});

test.each([
['boolean true', { captureAllSettledReasons: true }, undefined],
['empty object', { captureAllSettledReasons: {} }, undefined],
[
'contextBuilderFn returning CaptureContext',
{
captureAllSettledReasons: {
contextBuilderFn: jest.fn().mockResolvedValue({ tags: { promise: false } }),
},
},
{ tags: { promise: false } },
],
[
'contextBuilderFn returning Promise<CaptureContext>',
{
captureAllSettledReasons: {
contextBuilderFn: jest.fn().mockResolvedValue(Promise.resolve({ tags: { promise: true } })),
},
},
{ tags: { promise: true } },
],
])('captureAllSettledReasons is enable (%s)', async (_, config: Partial<WrapperOptions>, expectedContext) => {
const wrappedHandler = wrapHandler(handler, config);
await wrappedHandler(fakeEvent, fakeContext, fakeCallback);
if (typeof config.captureAllSettledReasons !== 'boolean' && config.captureAllSettledReasons?.contextBuilderFn) {
expect(config.captureAllSettledReasons?.contextBuilderFn).toHaveBeenNthCalledWith(1, fakeEvent, error, 0);
expect(config.captureAllSettledReasons?.contextBuilderFn).toHaveBeenNthCalledWith(2, fakeEvent, error2, 2);
expect(config.captureAllSettledReasons?.contextBuilderFn).toBeCalledTimes(2);
}
expect(Sentry.captureException).toHaveBeenNthCalledWith(1, error, expectedContext);
expect(Sentry.captureException).toHaveBeenNthCalledWith(2, error2, expectedContext);
expect(Sentry.captureException).toBeCalledTimes(2);
});
});
});

Expand Down