Skip to content

Tracing capabilities for @sentry/serverless. #2945

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

Merged
merged 3 commits into from
Oct 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/node/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ function extractUserData(
/**
* Options deciding what parts of the request to use when enhancing an event
*/
interface ParseRequestOptions {
export interface ParseRequestOptions {
ip?: boolean;
request?: boolean | string[];
serverName?: boolean;
Expand Down
15 changes: 13 additions & 2 deletions packages/serverless/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ Currently supported environment:

*AWS Lambda*

To use this SDK, call `Sentry.init(options)` at the very beginning of your JavaScript file.
To use this SDK, call `Sentry.AWSLambda.init(options)` at the very beginning of your JavaScript file.

```javascript
import * as Sentry from '@sentry/serverless';

Sentry.init({
Sentry.AWSLambda.init({
dsn: '__DSN__',
// ...
});
Expand All @@ -41,3 +41,14 @@ exports.handler = Sentry.AWSLambda.wrapHandler((event, context, callback) => {
throw new Error('oh, hello there!');
});
```

If you also want to trace performance of all the incoming requests and also outgoing AWS service requests, just set the `tracesSampleRate` option.

```javascript
import * as Sentry from '@sentry/serverless';

Sentry.AWSLambda.init({
dsn: '__DSN__',
tracesSampleRate: 1.0,
});
```
3 changes: 3 additions & 0 deletions packages/serverless/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@
"tslib": "^1.9.3"
},
"devDependencies": {
"@google-cloud/functions-framework": "^1.7.1",
"@sentry-internal/eslint-config-sdk": "5.25.0",
"@types/aws-lambda": "^8.10.62",
"@types/node": "^14.6.4",
"aws-sdk": "^2.765.0",
"eslint": "7.6.0",
"jest": "^24.7.1",
"nock": "^13.0.4",
"npm-run-all": "^4.1.2",
"prettier": "1.19.0",
"rimraf": "^2.6.3",
Expand Down
142 changes: 89 additions & 53 deletions packages/serverless/src/awslambda.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import { captureException, captureMessage, flush, Scope, SDK_VERSION, Severity, withScope } from '@sentry/node';
import {
captureException,
captureMessage,
flush,
getCurrentHub,
Scope,
SDK_VERSION,
Severity,
startTransaction,
withScope,
} from '@sentry/node';
import * as Sentry from '@sentry/node';
import { Integration } from '@sentry/types';
import { addExceptionMechanism } 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
import { Callback, Context, Handler } from 'aws-lambda';
import { Context, Handler } from 'aws-lambda';
import { hostname } from 'os';
import { performance } from 'perf_hooks';
import { types } from 'util';

import { AWSServices } from './awsservices';

export * from '@sentry/node';

const { isPromise } = types;

// https://www.npmjs.com/package/aws-lambda-consumer
Expand All @@ -21,14 +37,26 @@ export type AsyncHandler<T extends Handler> = (
context: Parameters<T>[1],
) => Promise<NonNullable<Parameters<Parameters<T>[2]>[1]>>;

interface WrapperOptions {
export interface WrapperOptions {
flushTimeout: number;
rethrowAfterCapture: boolean;
callbackWaitsForEmptyEventLoop: boolean;
captureTimeoutWarning: boolean;
timeoutWarningLimit: number;
}

export const defaultIntegrations: Integration[] = [...Sentry.defaultIntegrations, new AWSServices()];

/**
* @see {@link Sentry.init}
*/
export function init(options: Sentry.NodeOptions = {}): void {
if (options.defaultIntegrations === undefined) {
options.defaultIntegrations = defaultIntegrations;
}
return Sentry.init(options);
}

/**
* Add event processor that will override SDK details to point to the serverless SDK instead of Node,
* as well as set correct mechanism type, which should be set to `handled: false`.
Expand Down Expand Up @@ -98,36 +126,66 @@ function enhanceScopeWithEnvironmentData(scope: Scope, context: Context): void {
}

/**
* Capture, flush the result down the network stream and await the response.
* Capture exception with a a context.
*
* @param e exception to be captured
* @param options WrapperOptions
* @param context Context
*/
function captureExceptionAsync(e: unknown, context: Context, options: Partial<WrapperOptions>): Promise<boolean> {
function captureExceptionWithContext(e: unknown, context: Context): void {
withScope(scope => {
addServerlessEventProcessor(scope);
enhanceScopeWithEnvironmentData(scope, context);
captureException(e);
});
return flush(options.flushTimeout);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const wrapHandler = <TEvent = any, TResult = any>(
handler: Handler,
handlerOptions: Partial<WrapperOptions> = {},
): Handler => {
/**
* Wraps a lambda handler adding it error capture and tracing capabilities.
*
* @param handler Handler
* @param options Options
* @returns Handler
*/
export function wrapHandler<TEvent, TResult>(
handler: Handler<TEvent, TResult>,
wrapOptions: Partial<WrapperOptions> = {},
): Handler<TEvent, TResult | undefined> {
const options: WrapperOptions = {
flushTimeout: 2000,
rethrowAfterCapture: true,
callbackWaitsForEmptyEventLoop: false,
captureTimeoutWarning: true,
timeoutWarningLimit: 500,
...handlerOptions,
...wrapOptions,
};
let timeoutWarningTimer: NodeJS.Timeout;

return async (event: TEvent, context: Context, callback: Callback<TResult>) => {
// AWSLambda is like Express. It makes a distinction about handlers based on it's last argument
// async (event) => async handler
// async (event, context) => async handler
// (event, context, callback) => sync handler
// Nevertheless whatever option is chosen by user, we convert it to async handler.
const asyncHandler: AsyncHandler<typeof handler> =
handler.length > 2
? (event, context) =>
new Promise((resolve, reject) => {
const rv = (handler as SyncHandler<typeof handler>)(event, context, (error, result) => {
if (error === null || error === undefined) {
resolve(result!); // eslint-disable-line @typescript-eslint/no-non-null-assertion
} else {
reject(error);
}
}) as unknown;

// This should never happen, but still can if someone writes a handler as
// `async (event, context, callback) => {}`
if (isPromise(rv)) {
(rv as Promise<NonNullable<TResult>>).then(resolve, reject);
}
})
: (handler as AsyncHandler<typeof handler>);

return async (event, context) => {
context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop;

// In seconds. You cannot go any more granular than this in AWS Lambda.
Expand Down Expand Up @@ -155,50 +213,28 @@ export const wrapHandler = <TEvent = any, TResult = any>(
}, timeoutWarningDelay);
}

const callbackWrapper = <TResult>(
callback: Callback<TResult>,
resolve: (value?: unknown) => void,
reject: (reason?: unknown) => void,
): Callback<TResult> => {
return (...args) => {
clearTimeout(timeoutWarningTimer);
if (args[0] === null || args[0] === undefined) {
resolve(callback(...args));
} else {
captureExceptionAsync(args[0], context, options).finally(() => reject(callback(...args)));
}
};
};
const transaction = startTransaction({
name: context.functionName,
op: 'awslambda.handler',
});
// We put the transaction on the scope so users can attach children to it
getCurrentHub().configureScope(scope => {
scope.setSpan(transaction);
});

let rv: TResult | undefined;
try {
// AWSLambda is like Express. It makes a distinction about handlers based on it's last argument
// async (event) => async handler
// async (event, context) => async handler
// (event, context, callback) => sync handler
const isSyncHandler = handler.length === 3;
const handlerRv = isSyncHandler
? await new Promise((resolve, reject) => {
const rv = (handler as SyncHandler<Handler<TEvent, TResult>>)(
event,
context,
callbackWrapper(callback, resolve, reject),
);

// This should never happen, but still can if someone writes a handler as
// `async (event, context, callback) => {}`
if (isPromise(rv)) {
((rv as unknown) as Promise<TResult>).then(resolve, reject);
}
})
: await (handler as AsyncHandler<Handler<TEvent, TResult>>)(event, context);
clearTimeout(timeoutWarningTimer);
return handlerRv;
rv = await asyncHandler(event, context);
} catch (e) {
clearTimeout(timeoutWarningTimer);
await captureExceptionAsync(e, context, options);
captureExceptionWithContext(e, context);
if (options.rethrowAfterCapture) {
throw e;
}
} finally {
clearTimeout(timeoutWarningTimer);
transaction.finish();
await flush(options.flushTimeout);
}
return rv;
};
};
}
105 changes: 105 additions & 0 deletions packages/serverless/src/awsservices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { getCurrentHub } from '@sentry/node';
import { Integration, Span, Transaction } from '@sentry/types';
import { fill } from '@sentry/utils';
// 'aws-sdk/global' import is expected to be type-only so it's erased in the final .js file.
// When TypeScript compiler is upgraded, use `import type` syntax to explicitly assert that we don't want to load a module here.
import * as AWS from 'aws-sdk/global';

type GenericParams = { [key: string]: any }; // eslint-disable-line @typescript-eslint/no-explicit-any
type MakeRequestCallback<TResult> = (err: AWS.AWSError, data: TResult) => void;
// This interace could be replaced with just type alias once the `strictBindCallApply` mode is enabled.
interface MakeRequestFunction<TParams, TResult> extends CallableFunction {
(operation: string, params?: TParams, callback?: MakeRequestCallback<TResult>): AWS.Request<TResult, AWS.AWSError>;
}
interface AWSService {
serviceIdentifier: string;
}

/** AWS service requests tracking */
export class AWSServices implements Integration {
/**
* @inheritDoc
*/
public static id: string = 'AWSServices';

/**
* @inheritDoc
*/
public name: string = AWSServices.id;

/**
* @inheritDoc
*/
public setupOnce(): void {
const awsModule = require('aws-sdk/global') as typeof AWS;
fill(
awsModule.Service.prototype,
'makeRequest',
<TService extends AWSService, TResult>(
orig: MakeRequestFunction<GenericParams, TResult>,
): MakeRequestFunction<GenericParams, TResult> =>
function(this: TService, operation: string, params?: GenericParams, callback?: MakeRequestCallback<TResult>) {
let transaction: Transaction | undefined;
let span: Span | undefined;
const scope = getCurrentHub().getScope();
if (scope) {
transaction = scope.getTransaction();
}
const req = orig.call(this, operation, params);
req.on('afterBuild', () => {
if (transaction) {
span = transaction.startChild({
description: describe(this, operation, params),
op: 'request',
});
}
});
req.on('complete', () => {
if (span) {
span.finish();
}
});

if (callback) {
req.send(callback);
}
return req;
},
);
}
}

/** Describes an operation on generic AWS service */
function describe<TService extends AWSService>(service: TService, operation: string, params?: GenericParams): string {
let ret = `aws.${service.serviceIdentifier}.${operation}`;
if (params === undefined) {
return ret;
}
switch (service.serviceIdentifier) {
case 's3':
ret += describeS3Operation(operation, params);
break;
case 'lambda':
ret += describeLambdaOperation(operation, params);
break;
}
return ret;
}

/** Describes an operation on AWS Lambda service */
function describeLambdaOperation(_operation: string, params: GenericParams): string {
let ret = '';
if ('FunctionName' in params) {
ret += ` ${params.FunctionName}`;
}
return ret;
}

/** Describes an operation on AWS S3 service */
function describeS3Operation(_operation: string, params: GenericParams): string {
let ret = '';
if ('Bucket' in params) {
ret += ` ${params.Bucket}`;
}
return ret;
}
Loading