Skip to content

Commit

Permalink
Add AWSResources integration
Browse files Browse the repository at this point in the history
This integration traces AWS service calls as spans.
  • Loading branch information
marshall-lee committed Oct 12, 2020
1 parent 832d1ec commit e899040
Show file tree
Hide file tree
Showing 8 changed files with 293 additions and 5 deletions.
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,
});
```
2 changes: 2 additions & 0 deletions packages/serverless/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions packages/serverless/src/awslambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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`.
Expand Down
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;
}
1 change: 1 addition & 0 deletions packages/serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import * as AWSLambda from './awslambda';
import * as GCPFunction from './gcpfunction';
export { AWSLambda, GCPFunction };

export * from './awsservices';
export * from '@sentry/node';
11 changes: 11 additions & 0 deletions packages/serverless/test/__mocks__/@sentry/node.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
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 = {
Warning: 'warning',
};
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(),
setTransactionName: jest.fn(),
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);
Expand All @@ -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();
Expand Down
69 changes: 69 additions & 0 deletions packages/serverless/test/awsservices.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
});
Loading

0 comments on commit e899040

Please sign in to comment.