From 21f1a9c8f0981a8b4433283162e0508c53cfb629 Mon Sep 17 00:00:00 2001 From: Vladimir Kochnev Date: Wed, 30 Sep 2020 20:29:21 +0300 Subject: [PATCH 1/3] Enable tracing for AWSLambda. --- packages/serverless/src/awslambda.ts | 124 ++++++++++-------- .../serverless/test/__mocks__/@sentry/node.ts | 17 +++ packages/serverless/test/awslambda.test.ts | 70 +++++++--- 3 files changed, 139 insertions(+), 72 deletions(-) diff --git a/packages/serverless/src/awslambda.ts b/packages/serverless/src/awslambda.ts index 9f3a7720ad92..0f9aefe1ad04 100644 --- a/packages/serverless/src/awslambda.ts +++ b/packages/serverless/src/awslambda.ts @@ -1,8 +1,18 @@ -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 { 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'; @@ -21,7 +31,7 @@ export type AsyncHandler = ( context: Parameters[1], ) => Promise[2]>[1]>>; -interface WrapperOptions { +export interface WrapperOptions { flushTimeout: number; rethrowAfterCapture: boolean; callbackWaitsForEmptyEventLoop: boolean; @@ -98,36 +108,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): Promise { +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 = ( - handler: Handler, - handlerOptions: Partial = {}, -): Handler => { +/** + * Wraps a lambda handler adding it error capture and tracing capabilities. + * + * @param handler Handler + * @param options Options + * @returns Handler + */ +export function wrapHandler( + handler: Handler, + wrapOptions: Partial = {}, +): Handler { 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) => { + // 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 = + handler.length > 2 + ? (event, context) => + new Promise((resolve, reject) => { + const rv = (handler as SyncHandler)(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>).then(resolve, reject); + } + }) + : (handler as AsyncHandler); + + return async (event, context) => { context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop; // In seconds. You cannot go any more granular than this in AWS Lambda. @@ -155,50 +195,28 @@ export const wrapHandler = ( }, timeoutWarningDelay); } - const callbackWrapper = ( - callback: Callback, - resolve: (value?: unknown) => void, - reject: (reason?: unknown) => void, - ): Callback => { - 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>)( - 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).then(resolve, reject); - } - }) - : await (handler as AsyncHandler>)(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; }; -}; +} diff --git a/packages/serverless/test/__mocks__/@sentry/node.ts b/packages/serverless/test/__mocks__/@sentry/node.ts index 7db5eddab485..878daff47b72 100644 --- a/packages/serverless/test/__mocks__/@sentry/node.ts +++ b/packages/serverless/test/__mocks__/@sentry/node.ts @@ -2,23 +2,40 @@ export const SDK_VERSION = '6.6.6'; export const Severity = { Warning: 'warning', }; +export const fakeParentScope = { + setSpan: jest.fn(), +}; +export const fakeHub = { + configureScope: jest.fn((fn: (arg: any) => any) => fn(fakeParentScope)), +}; export const fakeScope = { addEventProcessor: jest.fn(), setTransactionName: jest.fn(), setTag: jest.fn(), setContext: jest.fn(), }; +export const fakeTransaction = { + finish: jest.fn(), +}; +export const getCurrentHub = jest.fn(() => fakeHub); +export const startTransaction = jest.fn(_ => fakeTransaction); export const captureException = jest.fn(); export const captureMessage = jest.fn(); export const withScope = jest.fn(cb => cb(fakeScope)); export const flush = jest.fn(() => Promise.resolve()); export const resetMocks = (): void => { + fakeTransaction.finish.mockClear(); + fakeParentScope.setSpan.mockClear(); + fakeHub.configureScope.mockClear(); + fakeScope.addEventProcessor.mockClear(); fakeScope.setTransactionName.mockClear(); fakeScope.setTag.mockClear(); fakeScope.setContext.mockClear(); + getCurrentHub.mockClear(); + startTransaction.mockClear(); captureException.mockClear(); captureMessage.mockClear(); withScope.mockClear(); diff --git a/packages/serverless/test/awslambda.test.ts b/packages/serverless/test/awslambda.test.ts index 63c2246b6d65..1f84eadec3f7 100644 --- a/packages/serverless/test/awslambda.test.ts +++ b/packages/serverless/test/awslambda.test.ts @@ -51,17 +51,11 @@ describe('AWSLambda', () => { test('flushTimeout', async () => { expect.assertions(1); - const error = new Error('wat'); - const handler = () => { - throw error; - }; + const handler = () => {}; const wrappedHandler = wrapHandler(handler, { flushTimeout: 1337 }); - try { - await wrappedHandler(fakeEvent, fakeContext, fakeCallback); - } catch (e) { - expect(Sentry.flush).toBeCalledWith(1337); - } + await wrappedHandler(fakeEvent, fakeContext, fakeCallback); + expect(Sentry.flush).toBeCalledWith(1337); }); test('rethrowAfterCapture', async () => { @@ -146,7 +140,7 @@ describe('AWSLambda', () => { describe('wrapHandler() on sync handler', () => { test('successful execution', async () => { - expect.assertions(1); + expect.assertions(5); const handler: Handler = (_event, _context, callback) => { callback(null, 42); @@ -154,10 +148,16 @@ describe('AWSLambda', () => { const wrappedHandler = wrapHandler(handler); const rv = await wrappedHandler(fakeEvent, fakeContext, fakeCallback); expect(rv).toStrictEqual(42); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'functionName', op: 'awslambda.handler' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); + expect(Sentry.flush).toBeCalledWith(2000); }); test('unsuccessful execution', async () => { - expect.assertions(2); + expect.assertions(5); const error = new Error('sorry'); const handler: Handler = (_event, _context, callback) => { @@ -168,7 +168,12 @@ describe('AWSLambda', () => { try { await wrappedHandler(fakeEvent, fakeContext, fakeCallback); } catch (e) { + expect(Sentry.startTransaction).toBeCalledWith({ name: 'functionName', op: 'awslambda.handler' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); expect(Sentry.captureException).toBeCalledWith(error); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); expect(Sentry.flush).toBeCalledWith(2000); } }); @@ -186,7 +191,7 @@ describe('AWSLambda', () => { }); test('capture error', async () => { - expect.assertions(2); + expect.assertions(5); const error = new Error('wat'); const handler: Handler = (_event, _context, _callback) => { @@ -197,7 +202,12 @@ describe('AWSLambda', () => { try { await wrappedHandler(fakeEvent, fakeContext, fakeCallback); } catch (e) { - expect(Sentry.captureException).toBeCalled(); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'functionName', op: 'awslambda.handler' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + expect(Sentry.captureException).toBeCalledWith(e); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); expect(Sentry.flush).toBeCalled(); } }); @@ -205,7 +215,7 @@ describe('AWSLambda', () => { describe('wrapHandler() on async handler', () => { test('successful execution', async () => { - expect.assertions(1); + expect.assertions(5); const handler: Handler = async (_event, _context) => { return 42; @@ -213,6 +223,12 @@ describe('AWSLambda', () => { const wrappedHandler = wrapHandler(handler); const rv = await wrappedHandler(fakeEvent, fakeContext, fakeCallback); expect(rv).toStrictEqual(42); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'functionName', op: 'awslambda.handler' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); + expect(Sentry.flush).toBeCalled(); }); test('event and context are correctly passed to the original handler', async () => { @@ -227,7 +243,7 @@ describe('AWSLambda', () => { }); test('capture error', async () => { - expect.assertions(2); + expect.assertions(5); const error = new Error('wat'); const handler: Handler = async (_event, _context) => { @@ -238,7 +254,12 @@ describe('AWSLambda', () => { try { await wrappedHandler(fakeEvent, fakeContext, fakeCallback); } catch (e) { - expect(Sentry.captureException).toBeCalled(); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'functionName', op: 'awslambda.handler' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + expect(Sentry.captureException).toBeCalledWith(error); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); expect(Sentry.flush).toBeCalled(); } }); @@ -246,7 +267,7 @@ describe('AWSLambda', () => { describe('wrapHandler() on async handler with a callback method (aka incorrect usage)', () => { test('successful execution', async () => { - expect.assertions(1); + expect.assertions(5); const handler: Handler = async (_event, _context, _callback) => { return 42; @@ -254,6 +275,12 @@ describe('AWSLambda', () => { const wrappedHandler = wrapHandler(handler); const rv = await wrappedHandler(fakeEvent, fakeContext, fakeCallback); expect(rv).toStrictEqual(42); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'functionName', op: 'awslambda.handler' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); + expect(Sentry.flush).toBeCalled(); }); test('event and context are correctly passed to the original handler', async () => { @@ -268,7 +295,7 @@ describe('AWSLambda', () => { }); test('capture error', async () => { - expect.assertions(2); + expect.assertions(5); const error = new Error('wat'); const handler: Handler = async (_event, _context, _callback) => { @@ -279,7 +306,12 @@ describe('AWSLambda', () => { try { await wrappedHandler(fakeEvent, fakeContext, fakeCallback); } catch (e) { - expect(Sentry.captureException).toBeCalled(); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'functionName', op: 'awslambda.handler' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + expect(Sentry.captureException).toBeCalledWith(error); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); expect(Sentry.flush).toBeCalled(); } }); From 832d1ecb3fe86a09f43a3fb33d0174d78c26a713 Mon Sep 17 00:00:00 2001 From: Vladimir Kochnev Date: Sat, 3 Oct 2020 03:16:05 +0300 Subject: [PATCH 2/3] Implement error handling and tracing for Google Cloud functions. --- packages/node/src/handlers.ts | 2 +- packages/serverless/package.json | 1 + .../src/gcpfunction/cloud_events.ts | 76 ++++ packages/serverless/src/gcpfunction/events.ts | 73 ++++ .../serverless/src/gcpfunction/general.ts | 69 +++ packages/serverless/src/gcpfunction/http.ts | 95 +++++ packages/serverless/src/gcpfunction/index.ts | 4 + packages/serverless/src/index.ts | 3 +- .../serverless/test/__mocks__/@sentry/node.ts | 4 + packages/serverless/test/gcpfunction.test.ts | 396 ++++++++++++++++++ yarn.lock | 16 +- 11 files changed, 734 insertions(+), 5 deletions(-) create mode 100644 packages/serverless/src/gcpfunction/cloud_events.ts create mode 100644 packages/serverless/src/gcpfunction/events.ts create mode 100644 packages/serverless/src/gcpfunction/general.ts create mode 100644 packages/serverless/src/gcpfunction/http.ts create mode 100644 packages/serverless/src/gcpfunction/index.ts create mode 100644 packages/serverless/test/gcpfunction.test.ts diff --git a/packages/node/src/handlers.ts b/packages/node/src/handlers.ts index a8af9001cbcc..0eba89e7dbb9 100644 --- a/packages/node/src/handlers.ts +++ b/packages/node/src/handlers.ts @@ -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; diff --git a/packages/serverless/package.json b/packages/serverless/package.json index a6f46eadfb84..6cb79e3837b9 100644 --- a/packages/serverless/package.json +++ b/packages/serverless/package.json @@ -23,6 +23,7 @@ "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", diff --git a/packages/serverless/src/gcpfunction/cloud_events.ts b/packages/serverless/src/gcpfunction/cloud_events.ts new file mode 100644 index 000000000000..9b9844d28940 --- /dev/null +++ b/packages/serverless/src/gcpfunction/cloud_events.ts @@ -0,0 +1,76 @@ +// '@google-cloud/functions-framework/build/src/functions' 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 { + CloudEventFunction, + CloudEventFunctionWithCallback, +} from '@google-cloud/functions-framework/build/src/functions'; +import { flush, getCurrentHub, startTransaction } from '@sentry/node'; +import { logger } from '@sentry/utils'; + +import { captureEventError, getActiveDomain, WrapperOptions } from './general'; + +export type CloudEventFunctionWrapperOptions = WrapperOptions; + +/** + * Wraps an event function handler adding it error capture and tracing capabilities. + * + * @param fn Event handler + * @param options Options + * @returns Event handler + */ +export function wrapCloudEventFunction( + fn: CloudEventFunction | CloudEventFunctionWithCallback, + wrapOptions: Partial = {}, +): CloudEventFunctionWithCallback { + const options: CloudEventFunctionWrapperOptions = { + flushTimeout: 2000, + ...wrapOptions, + }; + return (context, callback) => { + const transaction = startTransaction({ + name: context.type || '', + op: 'gcp.function.cloud_event', + }); + + // We put the transaction on the scope so users can attach children to it + getCurrentHub().configureScope(scope => { + scope.setSpan(transaction); + }); + + const activeDomain = getActiveDomain(); + + activeDomain.on('error', err => { + captureEventError(err, context); + }); + + const newCallback = activeDomain.bind((...args: unknown[]) => { + if (args[0] !== null && args[0] !== undefined) { + captureEventError(args[0], context); + } + transaction.finish(); + + flush(options.flushTimeout) + .then(() => { + callback(...args); + }) + .then(null, e => { + logger.error(e); + }); + }); + + if (fn.length > 1) { + return (fn as CloudEventFunctionWithCallback)(context, newCallback); + } + + Promise.resolve() + .then(() => (fn as CloudEventFunction)(context)) + .then( + result => { + newCallback(null, result); + }, + err => { + newCallback(err, undefined); + }, + ); + }; +} diff --git a/packages/serverless/src/gcpfunction/events.ts b/packages/serverless/src/gcpfunction/events.ts new file mode 100644 index 000000000000..80bd3a461bc4 --- /dev/null +++ b/packages/serverless/src/gcpfunction/events.ts @@ -0,0 +1,73 @@ +// '@google-cloud/functions-framework/build/src/functions' 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 { EventFunction, EventFunctionWithCallback } from '@google-cloud/functions-framework/build/src/functions'; +import { flush, getCurrentHub, startTransaction } from '@sentry/node'; +import { logger } from '@sentry/utils'; + +import { captureEventError, getActiveDomain, WrapperOptions } from './general'; + +export type EventFunctionWrapperOptions = WrapperOptions; + +/** + * Wraps an event function handler adding it error capture and tracing capabilities. + * + * @param fn Event handler + * @param options Options + * @returns Event handler + */ +export function wrapEventFunction( + fn: EventFunction | EventFunctionWithCallback, + wrapOptions: Partial = {}, +): EventFunctionWithCallback { + const options: EventFunctionWrapperOptions = { + flushTimeout: 2000, + ...wrapOptions, + }; + return (data, context, callback) => { + const transaction = startTransaction({ + name: context.eventType, + op: 'gcp.function.event', + }); + + // We put the transaction on the scope so users can attach children to it + getCurrentHub().configureScope(scope => { + scope.setSpan(transaction); + }); + + const activeDomain = getActiveDomain(); + + activeDomain.on('error', err => { + captureEventError(err, context); + }); + + const newCallback = activeDomain.bind((...args: unknown[]) => { + if (args[0] !== null && args[0] !== undefined) { + captureEventError(args[0], context); + } + transaction.finish(); + + flush(options.flushTimeout) + .then(() => { + callback(...args); + }) + .then(null, e => { + logger.error(e); + }); + }); + + if (fn.length > 2) { + return (fn as EventFunctionWithCallback)(data, context, newCallback); + } + + Promise.resolve() + .then(() => (fn as EventFunction)(data, context)) + .then( + result => { + newCallback(null, result); + }, + err => { + newCallback(err, undefined); + }, + ); + }; +} diff --git a/packages/serverless/src/gcpfunction/general.ts b/packages/serverless/src/gcpfunction/general.ts new file mode 100644 index 000000000000..8d61f89d9c8b --- /dev/null +++ b/packages/serverless/src/gcpfunction/general.ts @@ -0,0 +1,69 @@ +// '@google-cloud/functions-framework/build/src/functions' 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 { Context } from '@google-cloud/functions-framework/build/src/functions'; +import { captureException, Scope, SDK_VERSION, withScope } from '@sentry/node'; +import { Context as SentryContext } from '@sentry/types'; +import { addExceptionMechanism } from '@sentry/utils'; +import * as domain from 'domain'; +import { hostname } from 'os'; + +export interface WrapperOptions { + flushTimeout: number; +} + +/** + * Capture exception with additional event information. + * + * @param e exception to be captured + * @param context event context + */ +export function captureEventError(e: unknown, context: Context): void { + withScope(scope => { + addServerlessEventProcessor(scope); + scope.setContext('runtime', { + name: 'node', + version: global.process.version, + }); + scope.setTag('server_name', process.env.SENTRY_NAME || hostname()); + scope.setContext('gcp.function.context', { ...context } as SentryContext); + captureException(e); + }); +} + +/** + * 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`. + * We do it like this, so that we don't introduce any side-effects in this module, which makes it tree-shakeable. + * @param scope Scope that processor should be added to + */ +export function addServerlessEventProcessor(scope: Scope): void { + scope.addEventProcessor(event => { + event.sdk = { + ...event.sdk, + name: 'sentry.javascript.serverless', + integrations: [...((event.sdk && event.sdk.integrations) || []), 'GCPFunction'], + packages: [ + ...((event.sdk && event.sdk.packages) || []), + { + name: 'npm:@sentry/serverless', + version: SDK_VERSION, + }, + ], + version: SDK_VERSION, + }; + + addExceptionMechanism(event, { + handled: false, + }); + + return event; + }); +} + +/** + * @returns Current active domain with a correct type. + */ +export function getActiveDomain(): domain.Domain { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + return (domain as any).active as domain.Domain; +} diff --git a/packages/serverless/src/gcpfunction/http.ts b/packages/serverless/src/gcpfunction/http.ts new file mode 100644 index 000000000000..08f178f35c72 --- /dev/null +++ b/packages/serverless/src/gcpfunction/http.ts @@ -0,0 +1,95 @@ +// '@google-cloud/functions-framework/build/src/functions' 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 { HttpFunction } from '@google-cloud/functions-framework/build/src/functions'; +import { captureException, flush, getCurrentHub, Handlers, startTransaction, withScope } from '@sentry/node'; +import { logger, stripUrlQueryAndFragment } from '@sentry/utils'; + +import { addServerlessEventProcessor, getActiveDomain, WrapperOptions } from './general'; + +type Request = Parameters[0]; +type Response = Parameters[1]; +type ParseRequestOptions = Handlers.ParseRequestOptions; + +export interface HttpFunctionWrapperOptions extends WrapperOptions { + parseRequestOptions: ParseRequestOptions; +} + +export { Request, Response }; + +const { parseRequest } = Handlers; + +/** + * Capture exception with additional request information. + * + * @param e exception to be captured + * @param req incoming request + * @param options request capture options + */ +function captureRequestError(e: unknown, req: Request, options: ParseRequestOptions): void { + withScope(scope => { + addServerlessEventProcessor(scope); + scope.addEventProcessor(event => parseRequest(event, req, options)); + captureException(e); + }); +} + +/** + * Wraps an HTTP function handler adding it error capture and tracing capabilities. + * + * @param fn HTTP Handler + * @param options Options + * @returns HTTP handler + */ +export function wrapHttpFunction( + fn: HttpFunction, + wrapOptions: Partial = {}, +): HttpFunction { + const options: HttpFunctionWrapperOptions = { + flushTimeout: 2000, + parseRequestOptions: {}, + ...wrapOptions, + }; + return (req, res) => { + const reqMethod = (req.method || '').toUpperCase(); + const reqUrl = req.url && stripUrlQueryAndFragment(req.url); + + const transaction = startTransaction({ + name: `${reqMethod} ${reqUrl}`, + op: 'gcp.function.http', + }); + + // We put the transaction on the scope so users can attach children to it + getCurrentHub().configureScope(scope => { + scope.setSpan(transaction); + }); + + // We also set __sentry_transaction on the response so people can grab the transaction there to add + // spans to it later. + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (res as any).__sentry_transaction = transaction; + + // functions-framework creates a domain for each incoming request so we take advantage of this fact and add an error handler. + // BTW this is the only way to catch any exception occured during request lifecycle. + getActiveDomain().on('error', err => { + captureRequestError(err, req, options.parseRequestOptions); + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + const _end = res.end; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + res.end = function(chunk?: any | (() => void), encoding?: string | (() => void), cb?: () => void): void { + transaction.setHttpStatus(res.statusCode); + transaction.finish(); + + flush(options.flushTimeout) + .then(() => { + _end.call(this, chunk, encoding, cb); + }) + .then(null, e => { + logger.error(e); + }); + }; + + return fn(req, res); + }; +} diff --git a/packages/serverless/src/gcpfunction/index.ts b/packages/serverless/src/gcpfunction/index.ts new file mode 100644 index 000000000000..0db5992320c0 --- /dev/null +++ b/packages/serverless/src/gcpfunction/index.ts @@ -0,0 +1,4 @@ +export * from './http'; +export * from './events'; +export * from './cloud_events'; +export { init } from '@sentry/node'; diff --git a/packages/serverless/src/index.ts b/packages/serverless/src/index.ts index 4547a875d582..05758dde9265 100644 --- a/packages/serverless/src/index.ts +++ b/packages/serverless/src/index.ts @@ -1,5 +1,6 @@ // https://medium.com/unsplash/named-namespace-imports-7345212bbffb import * as AWSLambda from './awslambda'; -export { AWSLambda }; +import * as GCPFunction from './gcpfunction'; +export { AWSLambda, GCPFunction }; export * from '@sentry/node'; diff --git a/packages/serverless/test/__mocks__/@sentry/node.ts b/packages/serverless/test/__mocks__/@sentry/node.ts index 878daff47b72..387d825808e3 100644 --- a/packages/serverless/test/__mocks__/@sentry/node.ts +++ b/packages/serverless/test/__mocks__/@sentry/node.ts @@ -1,3 +1,5 @@ +const origSentry = jest.requireActual('@sentry/node'); +export const Handlers = origSentry.Handlers; // eslint-disable-line @typescript-eslint/no-unsafe-member-access export const SDK_VERSION = '6.6.6'; export const Severity = { Warning: 'warning', @@ -16,6 +18,7 @@ export const fakeScope = { }; export const fakeTransaction = { finish: jest.fn(), + setHttpStatus: jest.fn(), }; export const getCurrentHub = jest.fn(() => fakeHub); export const startTransaction = jest.fn(_ => fakeTransaction); @@ -25,6 +28,7 @@ export const withScope = jest.fn(cb => cb(fakeScope)); export const flush = jest.fn(() => Promise.resolve()); export const resetMocks = (): void => { + fakeTransaction.setHttpStatus.mockClear(); fakeTransaction.finish.mockClear(); fakeParentScope.setSpan.mockClear(); fakeHub.configureScope.mockClear(); diff --git a/packages/serverless/test/gcpfunction.test.ts b/packages/serverless/test/gcpfunction.test.ts new file mode 100644 index 000000000000..9b2a080d5fd9 --- /dev/null +++ b/packages/serverless/test/gcpfunction.test.ts @@ -0,0 +1,396 @@ +import { + CloudEventFunction, + CloudEventFunctionWithCallback, + EventFunction, + EventFunctionWithCallback, + HttpFunction, +} from '@google-cloud/functions-framework/build/src/functions'; +import { Event } from '@sentry/types'; +import * as domain from 'domain'; + +import * as Sentry from '../src'; +import { Request, Response, wrapCloudEventFunction, wrapEventFunction, wrapHttpFunction } from '../src/gcpfunction'; + +/** + * Why @ts-ignore some Sentry.X calls + * + * A hack-ish way to contain everything related to mocks in the same __mocks__ file. + * Thanks to this, we don't have to do more magic than necessary. Just add and export desired method and assert on it. + */ + +describe('GCPFunction', () => { + afterEach(() => { + // @ts-ignore see "Why @ts-ignore" note + Sentry.resetMocks(); + }); + + async function handleHttp(fn: HttpFunction): Promise { + return new Promise((resolve, _reject) => { + const d = domain.create(); + const req = { + method: 'GET', + host: 'hostname', + cookies: {}, + query: {}, + url: '/path', + headers: {}, + } as Request; + const res = { end: resolve } as Response; + d.on('error', () => res.end()); + d.run(() => process.nextTick(fn, req, res)); + }); + } + + function handleEvent(fn: EventFunctionWithCallback): Promise { + return new Promise((resolve, reject) => { + const d = domain.create(); + // d.on('error', () => res.end()); + const context = { + eventType: 'event.type', + resource: 'some.resource', + }; + d.on('error', reject); + d.run(() => + process.nextTick(fn, {}, context, (err: any, result: any) => { + if (err != null || err != undefined) { + reject(err); + } else { + resolve(result); + } + }), + ); + }); + } + + function handleCloudEvent(fn: CloudEventFunctionWithCallback): Promise { + return new Promise((resolve, reject) => { + const d = domain.create(); + // d.on('error', () => res.end()); + const context = { + type: 'event.type', + }; + d.on('error', reject); + d.run(() => + process.nextTick(fn, context, (err: any, result: any) => { + if (err != null || err != undefined) { + reject(err); + } else { + resolve(result); + } + }), + ); + }); + } + + describe('wrapHttpFunction() options', () => { + test('flushTimeout', async () => { + expect.assertions(1); + + const handler: HttpFunction = (_, res) => { + res.end(); + }; + const wrappedHandler = wrapHttpFunction(handler, { flushTimeout: 1337 }); + + await handleHttp(wrappedHandler); + expect(Sentry.flush).toBeCalledWith(1337); + }); + }); + + describe('wrapHttpFunction()', () => { + test('successful execution', async () => { + expect.assertions(5); + + const handler: HttpFunction = (_req, res) => { + res.statusCode = 200; + res.end(); + }; + const wrappedHandler = wrapHttpFunction(handler); + await handleHttp(wrappedHandler); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'GET /path', op: 'gcp.function.http' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.setHttpStatus).toBeCalledWith(200); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); + expect(Sentry.flush).toBeCalledWith(2000); + }); + + test('capture error', async () => { + expect.assertions(5); + + const error = new Error('wat'); + const handler: HttpFunction = (_req, _res) => { + throw error; + }; + const wrappedHandler = wrapHttpFunction(handler); + await handleHttp(wrappedHandler); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'GET /path', op: 'gcp.function.http' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + expect(Sentry.captureException).toBeCalledWith(error); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); + expect(Sentry.flush).toBeCalled(); + }); + }); + + describe('wrapEventFunction() without callback', () => { + test('successful execution', async () => { + expect.assertions(5); + + const func: EventFunction = (_data, _context) => { + return 42; + }; + const wrappedHandler = wrapEventFunction(func); + await expect(handleEvent(wrappedHandler)).resolves.toBe(42); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'event.type', op: 'gcp.function.event' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); + expect(Sentry.flush).toBeCalledWith(2000); + }); + + test('capture error', async () => { + expect.assertions(6); + + const error = new Error('wat'); + const handler: EventFunction = (_data, _context) => { + throw error; + }; + const wrappedHandler = wrapEventFunction(handler); + await expect(handleEvent(wrappedHandler)).rejects.toThrowError(error); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'event.type', op: 'gcp.function.event' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + expect(Sentry.captureException).toBeCalledWith(error); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); + expect(Sentry.flush).toBeCalled(); + }); + }); + + describe('wrapEventFunction() with callback', () => { + test('successful execution', async () => { + expect.assertions(5); + + const func: EventFunctionWithCallback = (_data, _context, cb) => { + cb(null, 42); + }; + const wrappedHandler = wrapEventFunction(func); + await expect(handleEvent(wrappedHandler)).resolves.toBe(42); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'event.type', op: 'gcp.function.event' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); + expect(Sentry.flush).toBeCalledWith(2000); + }); + + test('capture error', async () => { + expect.assertions(6); + + const error = new Error('wat'); + const handler: EventFunctionWithCallback = (_data, _context, cb) => { + cb(error); + }; + const wrappedHandler = wrapEventFunction(handler); + await expect(handleEvent(wrappedHandler)).rejects.toThrowError(error); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'event.type', op: 'gcp.function.event' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + expect(Sentry.captureException).toBeCalledWith(error); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); + expect(Sentry.flush).toBeCalled(); + }); + + test('capture exception', async () => { + expect.assertions(4); + + const error = new Error('wat'); + const handler: EventFunctionWithCallback = (_data, _context, _cb) => { + throw error; + }; + const wrappedHandler = wrapEventFunction(handler); + await expect(handleEvent(wrappedHandler)).rejects.toThrowError(error); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'event.type', op: 'gcp.function.event' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + expect(Sentry.captureException).toBeCalledWith(error); + }); + }); + + describe('wrapCloudEventFunction() without callback', () => { + test('successful execution', async () => { + expect.assertions(5); + + const func: CloudEventFunction = _context => { + return 42; + }; + const wrappedHandler = wrapCloudEventFunction(func); + await expect(handleCloudEvent(wrappedHandler)).resolves.toBe(42); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'event.type', op: 'gcp.function.cloud_event' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); + expect(Sentry.flush).toBeCalledWith(2000); + }); + + test('capture error', async () => { + expect.assertions(6); + + const error = new Error('wat'); + const handler: CloudEventFunction = _context => { + throw error; + }; + const wrappedHandler = wrapCloudEventFunction(handler); + await expect(handleCloudEvent(wrappedHandler)).rejects.toThrowError(error); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'event.type', op: 'gcp.function.cloud_event' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + expect(Sentry.captureException).toBeCalledWith(error); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); + expect(Sentry.flush).toBeCalled(); + }); + }); + + describe('wrapCloudEventFunction() with callback', () => { + test('successful execution', async () => { + expect.assertions(5); + + const func: CloudEventFunctionWithCallback = (_context, cb) => { + cb(null, 42); + }; + const wrappedHandler = wrapCloudEventFunction(func); + await expect(handleCloudEvent(wrappedHandler)).resolves.toBe(42); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'event.type', op: 'gcp.function.cloud_event' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); + expect(Sentry.flush).toBeCalledWith(2000); + }); + + test('capture error', async () => { + expect.assertions(6); + + const error = new Error('wat'); + const handler: CloudEventFunctionWithCallback = (_context, cb) => { + cb(error); + }; + const wrappedHandler = wrapCloudEventFunction(handler); + await expect(handleCloudEvent(wrappedHandler)).rejects.toThrowError(error); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'event.type', op: 'gcp.function.cloud_event' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + expect(Sentry.captureException).toBeCalledWith(error); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.finish).toBeCalled(); + expect(Sentry.flush).toBeCalled(); + }); + + test('capture exception', async () => { + expect.assertions(4); + + const error = new Error('wat'); + const handler: CloudEventFunctionWithCallback = (_context, _cb) => { + throw error; + }; + const wrappedHandler = wrapCloudEventFunction(handler); + await expect(handleCloudEvent(wrappedHandler)).rejects.toThrowError(error); + expect(Sentry.startTransaction).toBeCalledWith({ name: 'event.type', op: 'gcp.function.cloud_event' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction); + expect(Sentry.captureException).toBeCalledWith(error); + }); + }); + + test('enhance event with SDK info and correct mechanism value', async () => { + expect.assertions(2); + + const error = new Error('wat'); + const handler: HttpFunction = () => { + throw error; + }; + const wrappedHandler = wrapHttpFunction(handler); + + const eventWithSomeData = { + exception: { + values: [{}], + }, + sdk: { + integrations: ['SomeIntegration'], + packages: [ + { + name: 'some:@random/package', + version: '1337', + }, + ], + }, + }; + // @ts-ignore see "Why @ts-ignore" note + Sentry.fakeScope.addEventProcessor.mockImplementationOnce(cb => cb(eventWithSomeData)); + await handleHttp(wrappedHandler); + expect(eventWithSomeData).toEqual({ + exception: { + values: [ + { + mechanism: { + handled: false, + }, + }, + ], + }, + sdk: { + name: 'sentry.javascript.serverless', + integrations: ['SomeIntegration', 'GCPFunction'], + packages: [ + { + name: 'some:@random/package', + version: '1337', + }, + { + name: 'npm:@sentry/serverless', + version: '6.6.6', + }, + ], + version: '6.6.6', + }, + }); + + const eventWithoutAnyData: Event = { + exception: { + values: [{}], + }, + }; + // @ts-ignore see "Why @ts-ignore" note + Sentry.fakeScope.addEventProcessor.mockImplementationOnce(cb => cb(eventWithoutAnyData)); + await handleHttp(wrappedHandler); + expect(eventWithoutAnyData).toEqual({ + exception: { + values: [ + { + mechanism: { + handled: false, + }, + }, + ], + }, + sdk: { + name: 'sentry.javascript.serverless', + integrations: ['GCPFunction'], + packages: [ + { + name: 'npm:@sentry/serverless', + version: '6.6.6', + }, + ], + version: '6.6.6', + }, + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 1cf6ea417d4d..21c433841f16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1545,6 +1545,16 @@ retry-request "^4.0.0" teeny-request "^3.11.3" +"@google-cloud/functions-framework@^1.7.1": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@google-cloud/functions-framework/-/functions-framework-1.7.1.tgz#d29a27744a6eb2f95d840b86135b97b0d804a49e" + integrity sha512-jjG7nH94Thij97EPW2oQN28pVPRN3UEGcsCRi6RdaaiSyK32X40LN4WHntKVmQPBhqH+I0magHMk1pSb0McH2g== + dependencies: + body-parser "^1.18.3" + express "^4.16.4" + minimist "^1.2.0" + on-finished "^2.3.0" + "@google-cloud/paginator@^0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@google-cloud/paginator/-/paginator-0.2.0.tgz#eab2e6aa4b81df7418f6c51e2071f64dab2c2fa5" @@ -5047,7 +5057,7 @@ bn.js@^5.1.1: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.2.tgz#c9686902d3c9a27729f43ab10f9d79c2004da7b0" integrity sha512-40rZaf3bUNKTVYu9sIeeEGOg7g14Yvnj9kH7b50EiwX0Q7A6umbvfI5tvHaOERH0XigqKkfLkFQxzb4e6CIXnA== -body-parser@1.19.0, body-parser@^1.16.1: +body-parser@1.19.0, body-parser@^1.16.1, body-parser@^1.18.3: version "1.19.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== @@ -9143,7 +9153,7 @@ expect@^24.9.0: jest-message-util "^24.9.0" jest-regex-util "^24.9.0" -express@^4.10.7, express@^4.16.3, express@^4.17.1: +express@^4.10.7, express@^4.16.3, express@^4.16.4, express@^4.17.1: version "4.17.1" resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g== @@ -14183,7 +14193,7 @@ octokit-pagination-methods@^1.1.0: resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4" integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ== -on-finished@~2.3.0: +on-finished@^2.3.0, on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= From e8990406081a7d053f08c0006e5e560ca9cb141d Mon Sep 17 00:00:00 2001 From: Vladimir Kochnev Date: Sat, 3 Oct 2020 03:22:25 +0300 Subject: [PATCH 3/3] Add AWSResources integration This integration traces AWS service calls as spans. --- packages/serverless/README.md | 15 ++- packages/serverless/package.json | 2 + packages/serverless/src/awslambda.ts | 18 +++ packages/serverless/src/awsservices.ts | 105 ++++++++++++++++++ packages/serverless/src/index.ts | 1 + .../serverless/test/__mocks__/@sentry/node.ts | 11 ++ packages/serverless/test/awsservices.test.ts | 69 ++++++++++++ yarn.lock | 77 ++++++++++++- 8 files changed, 293 insertions(+), 5 deletions(-) create mode 100644 packages/serverless/src/awsservices.ts create mode 100644 packages/serverless/test/awsservices.test.ts diff --git a/packages/serverless/README.md b/packages/serverless/README.md index b89284024736..936d00e70716 100644 --- a/packages/serverless/README.md +++ b/packages/serverless/README.md @@ -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__', // ... }); @@ -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, +}); +``` diff --git a/packages/serverless/package.json b/packages/serverless/package.json index 6cb79e3837b9..721d51f173fa 100644 --- a/packages/serverless/package.json +++ b/packages/serverless/package.json @@ -27,8 +27,10 @@ "@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", diff --git a/packages/serverless/src/awslambda.ts b/packages/serverless/src/awslambda.ts index 0f9aefe1ad04..c531ef1f41e3 100644 --- a/packages/serverless/src/awslambda.ts +++ b/packages/serverless/src/awslambda.ts @@ -9,6 +9,8 @@ import { 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 @@ -17,6 +19,10 @@ 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 @@ -39,6 +45,18 @@ export interface WrapperOptions { 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`. diff --git a/packages/serverless/src/awsservices.ts b/packages/serverless/src/awsservices.ts new file mode 100644 index 000000000000..5c94aa22874b --- /dev/null +++ b/packages/serverless/src/awsservices.ts @@ -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 = (err: AWS.AWSError, data: TResult) => void; +// This interace could be replaced with just type alias once the `strictBindCallApply` mode is enabled. +interface MakeRequestFunction extends CallableFunction { + (operation: string, params?: TParams, callback?: MakeRequestCallback): AWS.Request; +} +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', + ( + orig: MakeRequestFunction, + ): MakeRequestFunction => + function(this: TService, operation: string, params?: GenericParams, callback?: MakeRequestCallback) { + 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(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; +} diff --git a/packages/serverless/src/index.ts b/packages/serverless/src/index.ts index 05758dde9265..d3eabf5dc269 100644 --- a/packages/serverless/src/index.ts +++ b/packages/serverless/src/index.ts @@ -3,4 +3,5 @@ import * as AWSLambda from './awslambda'; import * as GCPFunction from './gcpfunction'; export { AWSLambda, GCPFunction }; +export * from './awsservices'; export * from '@sentry/node'; diff --git a/packages/serverless/test/__mocks__/@sentry/node.ts b/packages/serverless/test/__mocks__/@sentry/node.ts index 387d825808e3..b364796956ba 100644 --- a/packages/serverless/test/__mocks__/@sentry/node.ts +++ b/packages/serverless/test/__mocks__/@sentry/node.ts @@ -1,4 +1,5 @@ const origSentry = jest.requireActual('@sentry/node'); +export const defaultIntegrations = origSentry.defaultIntegrations; // eslint-disable-line @typescript-eslint/no-unsafe-member-access export const Handlers = origSentry.Handlers; // eslint-disable-line @typescript-eslint/no-unsafe-member-access export const SDK_VERSION = '6.6.6'; export const Severity = { @@ -6,9 +7,11 @@ export const Severity = { }; export const fakeParentScope = { setSpan: jest.fn(), + getTransaction: jest.fn(() => fakeTransaction), }; export const fakeHub = { configureScope: jest.fn((fn: (arg: any) => any) => fn(fakeParentScope)), + getScope: jest.fn(() => fakeParentScope), }; export const fakeScope = { addEventProcessor: jest.fn(), @@ -16,9 +19,13 @@ export const fakeScope = { setTag: jest.fn(), setContext: jest.fn(), }; +export const fakeSpan = { + finish: jest.fn(), +}; export const fakeTransaction = { finish: jest.fn(), setHttpStatus: jest.fn(), + startChild: jest.fn(() => fakeSpan), }; export const getCurrentHub = jest.fn(() => fakeHub); export const startTransaction = jest.fn(_ => fakeTransaction); @@ -30,8 +37,12 @@ export const flush = jest.fn(() => Promise.resolve()); export const resetMocks = (): void => { fakeTransaction.setHttpStatus.mockClear(); fakeTransaction.finish.mockClear(); + fakeTransaction.startChild.mockClear(); + fakeSpan.finish.mockClear(); fakeParentScope.setSpan.mockClear(); + fakeParentScope.getTransaction.mockClear(); fakeHub.configureScope.mockClear(); + fakeHub.getScope.mockClear(); fakeScope.addEventProcessor.mockClear(); fakeScope.setTransactionName.mockClear(); diff --git a/packages/serverless/test/awsservices.test.ts b/packages/serverless/test/awsservices.test.ts new file mode 100644 index 000000000000..8cc3728b2f52 --- /dev/null +++ b/packages/serverless/test/awsservices.test.ts @@ -0,0 +1,69 @@ +import * as AWS from 'aws-sdk'; +import * as nock from 'nock'; + +import * as Sentry from '../src'; +import { AWSServices } from '../src/awsservices'; + +/** + * Why @ts-ignore some Sentry.X calls + * + * A hack-ish way to contain everything related to mocks in the same __mocks__ file. + * Thanks to this, we don't have to do more magic than necessary. Just add and export desired method and assert on it. + */ + +describe('AWSServices', () => { + beforeAll(() => { + new AWSServices().setupOnce(); + }); + afterEach(() => { + // @ts-ignore see "Why @ts-ignore" note + Sentry.resetMocks(); + }); + afterAll(() => { + nock.restore(); + }); + + describe('S3', () => { + const s3 = new AWS.S3({ accessKeyId: '-', secretAccessKey: '-' }); + + test('getObject', async () => { + nock('https://foo.s3.amazonaws.com') + .get('/bar') + .reply(200, 'contents'); + const data = await s3.getObject({ Bucket: 'foo', Key: 'bar' }).promise(); + expect(data.Body?.toString('utf-8')).toEqual('contents'); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.startChild).toBeCalledWith({ op: 'request', description: 'aws.s3.getObject foo' }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeSpan.finish).toBeCalled(); + }); + + test('getObject with callback', done => { + expect.assertions(3); + nock('https://foo.s3.amazonaws.com') + .get('/bar') + .reply(200, 'contents'); + s3.getObject({ Bucket: 'foo', Key: 'bar' }, (err, data) => { + expect(err).toBeNull(); + expect(data.Body?.toString('utf-8')).toEqual('contents'); + done(); + }); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.startChild).toBeCalledWith({ op: 'request', description: 'aws.s3.getObject foo' }); + }); + }); + + describe('Lambda', () => { + const lambda = new AWS.Lambda({ accessKeyId: '-', secretAccessKey: '-', region: 'eu-north-1' }); + + test('invoke', async () => { + nock('https://lambda.eu-north-1.amazonaws.com') + .post('/2015-03-31/functions/foo/invocations') + .reply(201, 'reply'); + const data = await lambda.invoke({ FunctionName: 'foo' }).promise(); + expect(data.Payload?.toString('utf-8')).toEqual('reply'); + // @ts-ignore see "Why @ts-ignore" note + expect(Sentry.fakeTransaction.startChild).toBeCalledWith({ op: 'request', description: 'aws.lambda.invoke foo' }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 21c433841f16..c1ab3bfbef86 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4226,6 +4226,21 @@ available-typed-arrays@^1.0.0, available-typed-arrays@^1.0.2: dependencies: array-filter "^1.0.0" +aws-sdk@^2.765.0: + version "2.765.0" + resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.765.0.tgz#34e71dc7336b4593d2f521cdcbbf7542a0d1f8cb" + integrity sha512-FQdPKJ5LAhNxkpqwrjQ+hiEqEOezV/PfZBn5RcBG6vu8K3VuT4dE12mGTY3qkdHy7lhymeRS5rWcagTmabKFtA== + dependencies: + buffer "4.9.2" + events "1.1.1" + ieee754 "1.1.13" + jmespath "0.15.0" + querystring "0.2.0" + sax "1.2.1" + url "0.10.3" + uuid "3.3.2" + xml2js "0.4.19" + aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" @@ -5887,7 +5902,7 @@ buffer-xor@^1.0.3: resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk= -buffer@^4.3.0: +buffer@4.9.2, buffer@^4.3.0: version "4.9.2" resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8" integrity sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg== @@ -9029,6 +9044,11 @@ events-to-array@^1.0.1: resolved "https://registry.yarnpkg.com/events-to-array/-/events-to-array-1.1.2.tgz#2d41f563e1fe400ed4962fe1a4d5c6a7539df7f6" integrity sha1-LUH1Y+H+QA7Uli/hpNXGp1Od9/Y= +events@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" + integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ= + events@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/events/-/events-3.2.0.tgz#93b87c18f8efcd4202a461aec4dfc0556b639379" @@ -10799,7 +10819,7 @@ icss-utils@^4.0.0, icss-utils@^4.1.1: dependencies: postcss "^7.0.14" -ieee754@^1.1.4: +ieee754@1.1.13, ieee754@^1.1.4: version "1.1.13" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== @@ -11968,6 +11988,11 @@ jest@^24.7.1: import-local "^2.0.0" jest-cli "^24.9.0" +jmespath@0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217" + integrity sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc= + jquery@^3.5.0: version "3.5.1" resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.5.1.tgz#d7b4d08e1bfdb86ad2f1a3d039ea17304717abb5" @@ -13764,6 +13789,16 @@ no-case@^3.0.3: lower-case "^2.0.1" tslib "^1.10.0" +nock@^13.0.4: + version "13.0.4" + resolved "https://registry.yarnpkg.com/nock/-/nock-13.0.4.tgz#9fb74db35d0aa056322e3c45be14b99105cd7510" + integrity sha512-alqTV8Qt7TUbc74x1pKRLSENzfjp4nywovcJgi/1aXDiUxXdt7TkruSTF5MDWPP7UoPVgea4F9ghVdmX0xxnSA== + dependencies: + debug "^4.1.0" + json-stringify-safe "^5.0.1" + lodash.set "^4.3.2" + propagate "^2.0.0" + node-environment-flags@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.5.tgz#fa930275f5bf5dae188d6192b24b4c8bbac3d76a" @@ -15338,6 +15373,11 @@ prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.6.2, prop-types@^15.7.2: object-assign "^4.1.1" react-is "^16.8.1" +propagate@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" + integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== + proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -16494,7 +16534,12 @@ sane@^4.0.0, sane@^4.0.3, sane@^4.1.0: minimist "^1.1.1" walker "~1.0.5" -sax@^1.2.4, sax@~1.2.4: +sax@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" + integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o= + +sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -18441,6 +18486,14 @@ url-to-options@^1.0.1: resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9" integrity sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k= +url@0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64" + integrity sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ= + dependencies: + punycode "1.3.2" + querystring "0.2.0" + url@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" @@ -18525,6 +18578,11 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= +uuid@3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" + integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== + uuid@^3.0.1, uuid@^3.3.2: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" @@ -19120,6 +19178,19 @@ xml-name-validator@^3.0.0: resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== +xml2js@0.4.19: + version "0.4.19" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7" + integrity sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q== + dependencies: + sax ">=0.6.0" + xmlbuilder "~9.0.1" + +xmlbuilder@~9.0.1: + version "9.0.7" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" + integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0= + xmlchars@^2.1.1, xmlchars@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"