Skip to content

Commit a514eb8

Browse files
committed
Add AWSResources integration
This integration traces AWS service calls as spans.
1 parent 98b8ae1 commit a514eb8

File tree

7 files changed

+272
-3
lines changed

7 files changed

+272
-3
lines changed

packages/serverless/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,14 @@ exports.handler = Sentry.AWSLambda.wrapHandler((event, context, callback) => {
4141
throw new Error('oh, hello there!');
4242
});
4343
```
44+
45+
If you also want to trace AWS service calls, add an `AWSResources` integration. They will be available to observe as nested spans for each transaction.
46+
47+
```javascript
48+
Sentry.init({
49+
dsn: '__DSN__',
50+
integrations: [
51+
new Sentry.AWSResources(),
52+
],
53+
});
54+
```

packages/serverless/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@
2727
"@sentry-internal/eslint-config-sdk": "5.25.0",
2828
"@types/aws-lambda": "^8.10.62",
2929
"@types/node": "^14.6.4",
30+
"aws-sdk": "^2.765.0",
3031
"eslint": "7.6.0",
3132
"jest": "^24.7.1",
33+
"nock": "^13.0.4",
3234
"npm-run-all": "^4.1.2",
3335
"prettier": "1.19.0",
3436
"rimraf": "^2.6.3",
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { getCurrentHub } from '@sentry/node';
2+
import { Integration, Span, Transaction } from '@sentry/types';
3+
import { fill } from '@sentry/utils';
4+
// 'aws-sdk/global' import is expected to be type-only so it's erased in the final .js file.
5+
// When TypeScript compiler is upgraded, use `import type` syntax to explicitly assert that we don't want to load a module here.
6+
import * as AWS from 'aws-sdk/global';
7+
8+
type GenericParams = { [key: string]: any }; // eslint-disable-line @typescript-eslint/no-explicit-any
9+
type MakeRequestCallback<TResult> = (err: AWS.AWSError, data: TResult) => void;
10+
// This interace could be replaced with just type alias once the `strictBindCallApply` mode is enabled.
11+
interface MakeRequestFunction<TParams, TResult> extends CallableFunction {
12+
(operation: string, params?: TParams, callback?: MakeRequestCallback<TResult>): AWS.Request<TResult, AWS.AWSError>;
13+
}
14+
interface AWSService {
15+
serviceIdentifier: string;
16+
}
17+
18+
/** AWS resource requests tracking */
19+
export class AWSResources implements Integration {
20+
/**
21+
* @inheritDoc
22+
*/
23+
public static id: string = 'AWSResources';
24+
25+
/**
26+
* @inheritDoc
27+
*/
28+
public name: string = AWSResources.id;
29+
30+
/**
31+
* @inheritDoc
32+
*/
33+
public setupOnce(): void {
34+
const awsModule = require('aws-sdk/global') as typeof AWS;
35+
fill(
36+
awsModule.Service.prototype,
37+
'makeRequest',
38+
<TService extends AWSService, TResult>(
39+
orig: MakeRequestFunction<GenericParams, TResult>,
40+
): MakeRequestFunction<GenericParams, TResult> =>
41+
function(this: TService, operation: string, params?: GenericParams, callback?: MakeRequestCallback<TResult>) {
42+
let transaction: Transaction | undefined;
43+
let span: Span | undefined;
44+
const scope = getCurrentHub().getScope();
45+
if (scope) {
46+
transaction = scope.getTransaction();
47+
}
48+
const req = orig.call(this, operation, params);
49+
req.on('afterBuild', () => {
50+
if (transaction) {
51+
span = transaction.startChild({
52+
description: describe(this, operation, params),
53+
op: 'request',
54+
});
55+
}
56+
});
57+
req.on('complete', () => {
58+
if (span) {
59+
span.finish();
60+
}
61+
});
62+
63+
if (callback) {
64+
req.send(callback);
65+
}
66+
return req;
67+
},
68+
);
69+
}
70+
}
71+
72+
/** Describes an operation on generic AWS service */
73+
function describe<TService extends AWSService>(service: TService, operation: string, params?: GenericParams): string {
74+
let ret = `aws.${service.serviceIdentifier}.${operation}`;
75+
if (params === undefined) {
76+
return ret;
77+
}
78+
switch (service.serviceIdentifier) {
79+
case 's3':
80+
ret += describeS3Operation(operation, params);
81+
break;
82+
case 'lambda':
83+
ret += describeLambdaOperation(operation, params);
84+
break;
85+
}
86+
return ret;
87+
}
88+
89+
/** Describes an operation on AWS Lambda service */
90+
function describeLambdaOperation(_operation: string, params: GenericParams): string {
91+
let ret = '';
92+
if ('FunctionName' in params) {
93+
ret += ` ${params.FunctionName}`;
94+
}
95+
return ret;
96+
}
97+
98+
/** Describes an operation on AWS S3 service */
99+
function describeS3Operation(_operation: string, params: GenericParams): string {
100+
let ret = '';
101+
if ('Bucket' in params) {
102+
ret += ` ${params.Bucket}`;
103+
}
104+
return ret;
105+
}

packages/serverless/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ import * as AWSLambda from './awslambda';
33
import * as GCPFunction from './gcpfunction';
44
export { AWSLambda, GCPFunction };
55

6+
export * from './awsresources';
67
export * from '@sentry/node';

packages/serverless/test/__mocks__/@sentry/node.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,25 @@ export const Severity = {
66
};
77
export const fakeParentScope = {
88
setSpan: jest.fn(),
9+
getTransaction: jest.fn(() => fakeTransaction),
910
};
1011
export const fakeHub = {
1112
configureScope: jest.fn((fn: (arg: any) => any) => fn(fakeParentScope)),
13+
getScope: jest.fn(() => fakeParentScope),
1214
};
1315
export const fakeScope = {
1416
addEventProcessor: jest.fn(),
1517
setTransactionName: jest.fn(),
1618
setTag: jest.fn(),
1719
setContext: jest.fn(),
1820
};
21+
export const fakeSpan = {
22+
finish: jest.fn(),
23+
};
1924
export const fakeTransaction = {
2025
finish: jest.fn(),
2126
setHttpStatus: jest.fn(),
27+
startChild: jest.fn(() => fakeSpan),
2228
};
2329
export const getCurrentHub = jest.fn(() => fakeHub);
2430
export const startTransaction = jest.fn(_ => fakeTransaction);
@@ -30,8 +36,12 @@ export const flush = jest.fn(() => Promise.resolve());
3036
export const resetMocks = (): void => {
3137
fakeTransaction.setHttpStatus.mockClear();
3238
fakeTransaction.finish.mockClear();
39+
fakeTransaction.startChild.mockClear();
40+
fakeSpan.finish.mockClear();
3341
fakeParentScope.setSpan.mockClear();
42+
fakeParentScope.getTransaction.mockClear();
3443
fakeHub.configureScope.mockClear();
44+
fakeHub.getScope.mockClear();
3545

3646
fakeScope.addEventProcessor.mockClear();
3747
fakeScope.setTransactionName.mockClear();
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import * as AWS from 'aws-sdk';
2+
import * as nock from 'nock';
3+
4+
import * as Sentry from '../src';
5+
import { AWSResources } from '../src/awsresources';
6+
7+
/**
8+
* Why @ts-ignore some Sentry.X calls
9+
*
10+
* A hack-ish way to contain everything related to mocks in the same __mocks__ file.
11+
* Thanks to this, we don't have to do more magic than necessary. Just add and export desired method and assert on it.
12+
*/
13+
14+
describe('AWSResources', () => {
15+
beforeAll(() => {
16+
new AWSResources().setupOnce();
17+
});
18+
afterEach(() => {
19+
// @ts-ignore see "Why @ts-ignore" note
20+
Sentry.resetMocks();
21+
});
22+
afterAll(() => {
23+
nock.restore();
24+
});
25+
26+
describe('S3', () => {
27+
const s3 = new AWS.S3({ accessKeyId: '-', secretAccessKey: '-' });
28+
29+
test('getObject', async () => {
30+
nock('https://foo.s3.amazonaws.com')
31+
.get('/bar')
32+
.reply(200, 'contents');
33+
const data = await s3.getObject({ Bucket: 'foo', Key: 'bar' }).promise();
34+
expect(data.Body?.toString('utf-8')).toEqual('contents');
35+
// @ts-ignore see "Why @ts-ignore" note
36+
expect(Sentry.fakeTransaction.startChild).toBeCalledWith({ op: 'request', description: 'aws.s3.getObject foo' });
37+
// @ts-ignore see "Why @ts-ignore" note
38+
expect(Sentry.fakeSpan.finish).toBeCalled();
39+
});
40+
41+
test('getObject with callback', done => {
42+
expect.assertions(3);
43+
nock('https://foo.s3.amazonaws.com')
44+
.get('/bar')
45+
.reply(200, 'contents');
46+
s3.getObject({ Bucket: 'foo', Key: 'bar' }, (err, data) => {
47+
expect(err).toBeNull();
48+
expect(data.Body?.toString('utf-8')).toEqual('contents');
49+
done();
50+
});
51+
// @ts-ignore see "Why @ts-ignore" note
52+
expect(Sentry.fakeTransaction.startChild).toBeCalledWith({ op: 'request', description: 'aws.s3.getObject foo' });
53+
});
54+
});
55+
56+
describe('Lambda', () => {
57+
const lambda = new AWS.Lambda({ accessKeyId: '-', secretAccessKey: '-', region: 'eu-north-1' });
58+
59+
test('invoke', async () => {
60+
nock('https://lambda.eu-north-1.amazonaws.com')
61+
.post('/2015-03-31/functions/foo/invocations')
62+
.reply(201, 'reply');
63+
const data = await lambda.invoke({ FunctionName: 'foo' }).promise();
64+
expect(data.Payload?.toString('utf-8')).toEqual('reply');
65+
// @ts-ignore see "Why @ts-ignore" note
66+
expect(Sentry.fakeTransaction.startChild).toBeCalledWith({ op: 'request', description: 'aws.lambda.invoke foo' });
67+
});
68+
});
69+
});

0 commit comments

Comments
 (0)