8000 Implement tracing of google cloud requests (#2981) · rchl/sentry-javascript@9b6f448 · GitHub
[go: up one dir, main page]

Skip to content

Commit 9b6f448

Browse files
Implement tracing of google cloud requests (getsentry#2981)
Co-authored-by: Kamil Ogórek <kamil.ogorek@gmail.com>
1 parent 3a7be5b commit 9b6f448

File tree

11 files changed

+892
-9
lines changed

11 files changed

+892
-9
lines changed

packages/serverless/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,16 @@
2323
"tslib": "^1.9.3"
2424
},
2525
"devDependencies": {
26+
"@google-cloud/common": "^3.4.1",
27+
"@google-cloud/pubsub": "^2.5.0",
28+
"@google-cloud/bigquery": "^5.3.0",
2629
"@google-cloud/functions-framework": "^1.7.1",
2730
"@sentry-internal/eslint-config-sdk": "5.26.0",
2831
"@types/aws-lambda": "^8.10.62",
2932
"@types/node": "^14.6.4",
3033
"aws-sdk": "^2.765.0",
3134
"eslint": "7.6.0",
35+
"google-gax": "^2.9.0",
3236
"jest": "^24.7.1",
3337
"nock": "^13.0.4",
3438
"npm-run-all": "^4.1.2",

packages/serverless/src/awsservices.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class AWSServices implements Integration {
5050
if (transaction) {
5151
span = transaction.startChild({
5252
description: describe(this, operation, params),
53-
op: 'request',
53+
op: 'aws.request',
5454
});
5555
}
5656
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
11
import * as Sentry from '@sentry/node';
2+
import { Integration } from '@sentry/types';
3+
4+
import { GoogleCloudGrpc } from '../google-cloud-grpc';
5+
import { GoogleCloudHttp } from '../google-cloud-http';
26

37
import { serverlessEventProcessor } from '../utils';
48

59
export * from './http';
610
export * from './events';
711
export * from './cloud_events';
812

13+
export const defaultIntegrations: Integration[] = [
14+
...Sentry.defaultIntegrations,
15+
new GoogleCloudHttp({ optional: true }), // We mark this integration optional since '@google-cloud/common' module could be missing.
16+
new GoogleCloudGrpc({ optional: true }), // We mark this integration optional since 'google-gax' module could be missing.
17+
];
18+
919
/**
1020
* @see {@link Sentry.init}
1121
*/
1222
export function init(options: Sentry.NodeOptions = {}): void {
23+
if (options.defaultIntegrations === undefined) {
24+
options.defaultIntegrations = defaultIntegrations;
25+
}
1326
Sentry.init(options);
1427
Sentry.addGlobalEventProcessor(serverlessEventProcessor('GCPFunction'));
1528
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { getCurrentHub } from '@sentry/node';
2+
import { Integration, Span, Transaction } from '@sentry/types';
3+
import { fill } from '@sentry/utils';
4+
import { EventEmitter } from 'events';
5+
6+
interface GrpcFunction extends CallableFunction {
7+
(...args: unknown[]): EventEmitter;
8+
}
9+
10+
interface GrpcFunctionObject extends GrpcFunction {
11+
requestStream: boolean;
12+
responseStream: boolean;
13+
originalName: string;
14+
}
15+
16+
interface StubOptions {
17+
servicePath?: string;
18+
}
19+
20+
interface CreateStubFunc extends CallableFunction {
21+
(createStub: unknown, options: StubOptions): PromiseLike<Stub>;
22+
}
23+
24+
interface Stub {
25+
[key: string]: GrpcFunctionObject;
26+
}
27+
28+
/** Google Cloud Platform service requests tracking for GRPC APIs */
29+
export class GoogleCloudGrpc implements Integration {
30+
/**
31+
* @inheritDoc
32+
*/
33+
public static id: string = 'GoogleCloudGrpc';
34+
35+
/**
36+
* @inheritDoc
37+
*/
38+
public name: string = GoogleCloudGrpc.id;
39+
40+
private readonly _optional: boolean;
41+
42+
public constructor(options: { optional?: boolean } = {}) {
43+
this._optional = options.optional || false;
44+
}
45+
46+
/**
47+
* @inheritDoc
48+
*/
49+
public setupOnce(): void {
50+
try {
51+
const gaxModule = require('google-gax');
52+
fill(
53+
gaxModule.GrpcClient.prototype, // eslint-disable-line @typescript-eslint/no-unsafe-member-access
54+
'createStub',
55+
wrapCreateStub,
56+
);
57+
} catch (e) {
58+
if (!this._optional) {
59+
throw e;
60+
}
61+
}
62+
}
63+
}
64+
65+
/** Returns a wrapped function that returns a stub with tracing enabled */
66+
function wrapCreateStub(origCreate: CreateStubFunc): CreateStubFunc {
67+
return async function(this: unknown, ...args: Parameters<CreateStubFunc>) {
68+
const servicePath = args[1]?.servicePath;
69+
if (servicePath == null || servicePath == undefined) {
70+
return origCreate.apply(this, args);
71+
}
72+
const serviceIdentifier = identifyService(servicePath);
73+
const stub = await origCreate.apply(this, args);
74+
for (const methodName of Object.keys(Object.getPrototypeOf(stub))) {
75+
fillGrpcFunction(stub, serviceIdentifier, methodName);
76+
}
77+
return stub;
78+
};
79+
}
80+
81+
/** Patches the function in grpc stub to enable tracing */
82+
function fillGrpcFunction(stub: Stub, serviceIdentifier: string, methodName: string): void {
83+
const funcObj = stub[methodName];
84+
if (typeof funcObj !== 'function') {
85+
return;
86+
}
87+
const callType =
88+
!funcObj.requestStream && !funcObj.responseStream
89+
? 'unary call'
90+
: funcObj.requestStream && !funcObj.responseStream
91+
? 'client stream'
92+
: !funcObj.requestStream && funcObj.responseStream
93+
? 'server stream'
94+
: 'bidi stream';
95+
if (callType != 'unary call') {
96+
return;
97+
}
98+
fill(
99+
stub,
100+
methodName,
101+
(orig: GrpcFunction): GrpcFunction => (...args) => {
102+
const ret = orig.apply(stub, args);
103+
if (typeof ret?.on !== 'function') {
104+
return ret;
105+
}
106+
let transaction: Transaction | undefined;
107+
let span: Span | undefined;
108+
const scope = getCurrentHub().getScope();
109+
if (scope) {
110+
transaction = scope.getTransaction();
111+
}
112+
if (transaction) {
113+
span = transaction.startChild({
114+
description: `${callType} ${methodName}`,
115+
op: `gcloud.grpc.${serviceIdentifier}`,
116+
});
117+
}
118+
ret.on('status', () => {
119+
if (span) {
120+
span.finish();
121+
}
122+
});
123+
return ret;
124+
},
125+
);
126+
}
127+
128+
/** Identifies service by its address */
129+
function identifyService(servicePath: string): string {
130+
const match = servicePath.match(/^(\w+)\.googleapis.com$/);
131+
return match ? match[1] : servicePath;
132+
}
Lines changed: 77 additions & 0 deletions
< F438 /div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// '@google-cloud/common' import is expected to be type-only so it's erased in the final .js file.
2+
// When TypeScript compiler is upgraded, use `import type` syntax to explicitly assert that we don't want to load a module here.
3+
import * as common from '@google-cloud/common';
4+
import { getCurrentHub } from '@sentry/node';
5+
import { Integration, Span, Transaction } from '@sentry/types';
6+
import { fill } from '@sentry/utils';
7+
8+
type RequestOptions = common.DecorateRequestOptions;
9+
type ResponseCallback = common.BodyResponseCallback;
10+
// This interace could be replaced with just type alias once the `strictBindCallApply` mode is enabled.
11+
interface RequestFunction extends CallableFunction {
12+
(reqOpts: RequestOptions, callback: ResponseCallback): void;
13+
}
14+
15+
/** Google Cloud Platform service requests tracking for RESTful APIs */
16+
export class GoogleCloudHttp implements Integration {
17+
/**
18+
* @inheritDoc
19+
*/
20+
public static id: string = 'GoogleCloudHttp';
21+
22+
/**
23+
* @inheritDoc
24+
*/
25+
public name: string = GoogleCloudHttp.id;
26+
27+
private readonly _optional: boolean;
28+
29+
public constructor(options: { optional?: boolean } = {}) {
30+
this._optional = options.optional || false;
31+
}
32+
33+
/**
34+
* @inheritDoc
35+
*/
36+
public setupOnce(): void {
37+
try {
38+
const commonModule = require('@google-cloud/common') as typeof common;
39+
fill(commonModule.Service.prototype, 'request', wrapRequestFunction);
40+
} catch (e) {
41+
if (!this._optional) {
42+
throw e;
43+
}
44+
}
45+
}
46+
}
47+
48+
/** Returns a wrapped function that makes a request with tracing enabled */
49+
function wrapRequestFunction(orig: RequestFunction): RequestFunction {
50+
return function(this: common.Service, reqOpts: RequestOptions, callback: ResponseCallback): void {
51+
let transaction: Transaction | undefined;
52+
let span: Span | undefined;
53+
const scope = getCurrentHub().getScope();
54+
if (scope) {
55+
transaction = scope.getTransaction();
56+
}
57+
if (transaction) {
58+
const httpMethod = reqOpts.method || 'GET';
59+
span = transaction.startChild({
60+
description: `${httpMethod} ${reqOpts.uri}`,
61+
op: `gcloud.http.${identifyService(this.apiEndpoint)}`,
62+
});
63+
}
64+
orig.call(this, reqOpts, (...args: Parameters<ResponseCallback>) => {
65+
if (span) {
66+
span.finish();
67+
}
68+
callback(...args);
69+
});
70+
};
71+
}
72+
73+
/** Identifies service by its base url */
74+
function identifyService(apiEndpoint: string): string {
75+
const match = apiEndpoint.match(/^https:\/\/(\w+)\.googleapis.com$/);
76+
return match ? match[1] : apiEndpoint.replace(/^(http|https)?:\/\//, '');
77+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const lookup = jest.fn();
2+
export const resolveTxt = jest.fn();

packages/serverless/test/awsservices.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ describe('AWSServices', () => {
3333
const data = await s3.getObject({ Bucket: 'foo', Key: 'bar' }).promise();
3434
expect(data.Body?.toString('utf-8')).toEqual('contents');
3535
// @ts-ignore see "Why @ts-ignore" note
36-
expect(Sentry.fakeTransaction.startChild).toBeCalledWith({ op: 'request', description: 'aws.s3.getObject foo' });
36+
expect(Sentry.fakeTransaction.startChild).toBeCalledWith({
37+
op: 'aws.request',
38+
description: 'aws.s3.getObject foo',
39+
});
3740
// @ts-ignore see "Why @ts-ignore" note
3841
expect(Sentry.fakeSpan.finish).toBeCalled();
3942
});
@@ -49,7 +52,10 @@ describe('AWSServices', () => {
4952
done();
5053
});
5154
// @ts-ignore see "Why @ts-ignore" note
52-
expect(Sentry.fakeTransaction.startChild).toBeCalledWith({ op: 'request', description: 'aws.s3.getObject foo' });
55+
expect(Sentry.fakeTransaction.startChild).toBeCalledWith({
56+
op: 'aws.request',
57+
description: 'aws.s3.getObject foo',
58+
});
5359
});
5460
});
5561

@@ -63,7 +69,10 @@ describe('AWSServices', () => {
6369
const data = await lambda.invoke({ FunctionName: 'foo' }).promise();
6470
expect(data.Payload?.toString('utf-8')).toEqual('reply');
6571
// @ts-ignore see "Why @ts-ignore" note
66-
expect(Sentry.fakeTransaction.startChild).toBeCalledWith({ op: 'request', description: 'aws.lambda.invoke foo' });
72+
expect(Sentry.fakeTransaction.startChild).toBeCalledWith({
73+
op: 'aws.request',
74+
description: 'aws.lambda.invoke foo',
75+
});
6776
});
6877
});
6978
});

0 commit comments

Comments
 (0)
0