Skip to content

Commit a12a493

Browse files
committed
ref: Rewrite grpc test to be less accurate, but more stable
This rewrite makes the tests less accurate because we are no longer testing against google-gax directly, but this feels like something we should be doing in our integration tests instead. As an alternative we check that we are filling the prototype correctly, and then if we are patching the google-gax stub properly. This also allows us to drop googleapis and google-gax as dev deps.
1 parent 2114e28 commit a12a493

File tree

4 files changed

+177
-129
lines changed

4 files changed

+177
-129
lines changed

packages/google-cloud-serverless/package.json

-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@
5858
"@google-cloud/functions-framework": "^1.7.1",
5959
"@google-cloud/pubsub": "^2.5.0",
6060
"@types/node": "^18.19.1",
61-
"google-gax": "^2.9.0",
6261
"nock": "^13.5.5"
6362
},
6463
"scripts": {

packages/google-cloud-serverless/src/integrations/google-cloud-grpc.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import type { Client, IntegrationFn } from '@sentry/core';
33
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, defineIntegration, fill, getClient } from '@sentry/core';
44
import { startInactiveSpan } from '@sentry/node';
55

6-
interface GrpcFunction extends CallableFunction {
6+
export interface GrpcFunction extends CallableFunction {
77
(...args: unknown[]): EventEmitter;
88
}
99

10-
interface GrpcFunctionObject extends GrpcFunction {
10+
export interface GrpcFunctionObject extends GrpcFunction {
1111
requestStream: boolean;
1212
responseStream: boolean;
1313
originalName: string;
@@ -21,7 +21,7 @@ interface CreateStubFunc extends CallableFunction {
2121
(createStub: unknown, options: StubOptions): PromiseLike<Stub>;
2222
}
2323

24-
interface Stub {
24+
export interface Stub {
2525
[key: string]: GrpcFunctionObject;
2626
}
2727

@@ -78,7 +78,7 @@ function wrapCreateStub(origCreate: CreateStubFunc): CreateStubFunc {
7878
}
7979

8080
/** Patches the function in grpc stub to enable tracing */
81-
function fillGrpcFunction(stub: Stub, serviceIdentifier: string, methodName: string): void {
81+
export function fillGrpcFunction(stub: Stub, serviceIdentifier: string, methodName: string): void {
8282
const funcObj = stub[methodName];
8383
if (typeof funcObj !== 'function') {
8484
return;
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,30 @@
1-
import * as dns from 'dns';
2-
import { EventEmitter } from 'events';
3-
import * as fs from 'fs';
4-
import * as path from 'path';
5-
import { PubSub } from '@google-cloud/pubsub';
6-
import * as http2 from 'http2';
7-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
8-
// @ts-ignore ESM/CJS interop issue
9-
import nock from 'nock';
10-
import { describe, vi, beforeEach, test, expect, type Mock, afterAll, afterEach } from 'vitest';
11-
12-
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
13-
import { NodeClient, createTransport, setCurrentClient } from '@sentry/node';
14-
import { googleCloudGrpcIntegration } from '../../src/integrations/google-cloud-grpc';
15-
16-
vi.mock('dns');
17-
vi.mock('http2');
18-
19-
const spyConnect = vi.spyOn(http2, 'connect');
1+
import { vi, describe, beforeEach, test, expect } from 'vitest';
2+
import { NodeClient } from '@sentry/node';
3+
import { createTransport } from '@sentry/core';
4+
import { setCurrentClient, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
5+
import { googleCloudGrpcIntegration, fillGrpcFunction } from '../../src/integrations/google-cloud-grpc';
6+
import type { GrpcFunctionObject, Stub, GrpcFunction } from '../../src/integrations/google-cloud-grpc';
7+
208
const mockSpanEnd = vi.fn();
21-
const mockStartInactiveSpan = vi.fn(spanArgs => ({ ...spanArgs }));
9+
const mockStartInactiveSpan = vi.fn();
10+
const mockFill = vi.fn();
11+
12+
let mockClient: NodeClient;
13+
14+
vi.mock('@sentry/core', async () => {
15+
const original = await vi.importActual('@sentry/core');
16+
return {
17+
...original,
18+
fill: (obj: any, name: string, replacement: any) => {
19+
mockFill(obj, name, replacement);
20+
obj[name] = replacement(obj[name]);
21+
},
22+
getClient: () => mockClient,
23+
};
24+
});
2225

2326
vi.mock('@sentry/node', async () => {
24-
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
25-
const original = (await vi.importActual('@sentry/node')) as typeof import('@sentry/node');
27+
const original = await vi.importActual('@sentry/node');
2628
return {
2729
...original,
2830
startInactiveSpan: (ctx: unknown) => {
@@ -32,130 +34,177 @@ vi.mock('@sentry/node', async () => {
3234
};
3335
});
3436

35-
/** Fake HTTP2 stream */
36-
class FakeStream extends EventEmitter {
37-
public rstCode: number = 0;
38-
close() {
39-
this.emit('end');
40-
this.emit('close');
41-
}
42-
end() {}
43-
pause() {}
44-
resume() {}
45-
write(_data: Buffer, cb: CallableFunction) {
46-
process.nextTick(cb, null);
47-
}
48-
}
49-
50-
/** Fake HTTP2 session for GRPC */
51-
class FakeSession extends EventEmitter {
52-
public socket: EventEmitter = new EventEmitter();
53-
public request: Mock = vi.fn();
54-
ping() {}
55-
mockRequest(fn: (stream: FakeStream) => void): FakeStream {
56-
const stream = new FakeStream();
57-
this.request.mockImplementationOnce(() => {
58-
process.nextTick(fn, stream);
59-
return stream;
60-
});
61-
return stream;
62-
}
63-
mockUnaryRequest(responseData: Buffer) {
64-
this.mockRequest(stream => {
65-
stream.emit(
66-
'response',
67-
{ ':status': 200, 'content-type': 'application/grpc', 'content-disposition': 'attachment' },
68-
4,
69-
);
70-
stream.emit('data', responseData);
71-
stream.emit('trailers', { 'grpc-status': '0', 'content-disposition': 'attachment' });
72-
});
73-
}
74-
close() {
75-
this.emit('close');
76-
this.socket.emit('close');
77-
}
78-
ref() {}
79-
unref() {}
37+
// Need to override mock because the integration loads google-gax as a CJS file
38+
async function mock(mockedUri: string, stub: any) {
39+
// @ts-expect-error we are using import on purpose
40+
const { Module } = await import('module');
41+
42+
// @ts-expect-error test
43+
Module._load_original = Module._load;
44+
// @ts-expect-error test
45+
Module._load = (uri, parent) => {
46+
if (uri === mockedUri) return stub;
47+
// @ts-expect-error test
48+
return Module._load_original(uri, parent);
49+
};
8050
}
8151

82-
function mockHttp2Session(): FakeSession {
83-
const session = new FakeSession();
84-
spyConnect.mockImplementationOnce(() => {
85-
process.nextTick(() => session.emit('connect'));
86-
return session as unknown as http2.ClientHttp2Session;
87-
});
88-
return session;
89-
}
52+
vi.hoisted(
53+
() =>
54+
void mock('google-gax', {
55+
GrpcClient: {
56+
prototype: {
57+
createStub: vi.fn(),
58+
},
59+
},
60+
}),
61+
);
9062

9163
describe('GoogleCloudGrpc tracing', () => {
92-
const mockClient = new NodeClient({
93-
tracesSampleRate: 1.0,
94-
integrations: [],
95-
dsn: 'https://withAWSServices@domain/123',
96-
transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})),
97-
stackParser: () => [],
98-
});
64+
beforeEach(() => {
65+
mockClient = new NodeClient({
66+
tracesSampleRate: 1.0,
67+
integrations: [],
68+
dsn: 'https://withAWSServices@domain/123',
69+
transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})),
70+
stackParser: () => [],
71+
});
9972

100-
const integration = googleCloudGrpcIntegration();
101-
mockClient.addIntegration(integration);
73+
const integration = googleCloudGrpcIntegration();
74+
mockClient.addIntegration(integration);
75+
integration.setup?.(mockClient);
10276

103-
beforeEach(() => {
104-
nock('https://www.googleapis.com').post('/oauth2/v4/token').reply(200, []);
10577
setCurrentClient(mockClient);
10678
mockSpanEnd.mockClear();
10779
mockStartInactiveSpan.mockClear();
80+
mockFill.mockClear();
10881
});
10982

110-
afterAll(() => {
111-
nock.restore();
112-
spyConnect.mockRestore();
113-
});
114-
115-
// We use google cloud pubsub as an example of grpc service for which we can trace requests.
116-
describe('pubsub', () => {
117-
// @ts-expect-error see "Why @ts-expect-error" note
118-
const dnsLookup = dns.lookup as Mock;
119-
// @ts-expect-error see "Why @ts-expect-error" note
120-
const resolveTxt = dns.resolveTxt as Mock;
121-
dnsLookup.mockImplementation((hostname, ...args) => {
122-
expect(hostname).toEqual('pubsub.googleapis.com');
123-
process.nextTick(args[args.length - 1], null, [{ address: '0.0.0.0', family: 4 }]);
124-
});
125-
resolveTxt.mockImplementation((hostname, cb) => {
126-
expect(hostname).toEqual('pubsub.googleapis.com');
127-
process.nextTick(cb, null, []);
83+
describe('setup', () => {
84+
test('integration name is correct', () => {
85+
const integration = googleCloudGrpcIntegration();
86+
expect(integration.name).toBe('GoogleCloudGrpc');
12887
});
12988

130-
const pubsub = new PubSub({
131-
credentials: {
132-
client_email: 'client@email',
133-
private_key: fs.readFileSync(path.resolve(__dirname, 'private.pem')).toString(),
134-
},
135-
projectId: 'project-id',
89+
test('setupOnce patches GrpcClient.createStub', () => {
90+
const mockCreateStub = vi.fn();
91+
const mockGrpcClient = {
92+
prototype: {
93+
createStub: mockCreateStub,
94+
},
95+
};
96+
97+
// eslint-disable-next-line @typescript-eslint/no-var-requires
98+
require('google-gax').GrpcClient = mockGrpcClient;
99+
100+
const integration = googleCloudGrpcIntegration();
101+
integration.setupOnce?.();
102+
expect(mockCreateStub).toBeDefined();
136103
});
137104

138-
afterEach(() => {
139-
dnsLookup.mockReset();
140-
resolveTxt.mockReset();
105+
test('setupOnce throws when google-gax is not available and not optional', () => {
106+
// eslint-disable-next-line @typescript-eslint/no-var-requires
107+
require('google-gax').GrpcClient = undefined;
108+
109+
const integration = googleCloudGrpcIntegration();
110+
expect(() => integration.setupOnce?.()).toThrow();
141111
});
142112

143-
afterAll(async () => {
144-
await pubsub.close();
113+
test('setupOnce does not throw when google-gax is not available and optional', () => {
114+
// eslint-disable-next-line @typescript-eslint/no-var-requires
115+
require('google-gax').GrpcClient = undefined;
116+
117+
const optionalIntegration = googleCloudGrpcIntegration({ optional: true });
118+
expect(() => optionalIntegration.setupOnce?.()).not.toThrow();
145119
});
120+
});
146121

147-
test('publish', async () => {
148-
mockHttp2Session().mockUnaryRequest(Buffer.from('00000000120a1031363337303834313536363233383630', 'hex'));
149-
const resp = await pubsub.topic('nicetopic').publish(Buffer.from('data'));
150-
expect(resp).toEqual('1637084156623860');
151-
expect(mockStartInactiveSpan).toBeCalledWith({
152-
op: 'grpc.pubsub',
122+
describe('fillGrpcFunction', () => {
123+
test('patches unary call methods with tracing', () => {
124+
const mockStub: Stub = {
125+
unaryMethod: Object.assign(vi.fn(), {
126+
requestStream: false,
127+
responseStream: false,
128+
originalName: 'unaryMethod',
129+
} as GrpcFunctionObject),
130+
};
131+
132+
const mockEventEmitter = {
133+
on: vi.fn(),
134+
};
135+
136+
(mockStub.unaryMethod as any).apply = vi.fn().mockReturnValue(mockEventEmitter);
137+
138+
fillGrpcFunction(mockStub, 'test-service', 'unaryMethod');
139+
140+
const result = (mockStub.unaryMethod as GrpcFunction)();
141+
expect(result).toBe(mockEventEmitter);
142+
expect(mockEventEmitter.on).toHaveBeenCalledWith('status', expect.any(Function));
143+
expect(mockStartInactiveSpan).toHaveBeenCalledWith({
144+
name: 'unary call unaryMethod',
145+
onlyIfParent: true,
146+
op: 'grpc.test-service',
153147
attributes: {
154148
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.grpc.serverless',
155149
},
156-
name: 'unary call publish',
157-
onlyIfParent: true,
158150
});
159151
});
152+
153+
test('does not patch non-unary call methods', () => {
154+
const mockStub: Stub = {
155+
clientStreamMethod: Object.assign(vi.fn(), {
156+
requestStream: true,
157+
responseStream: false,
158+
originalName: 'clientStreamMethod',
159+
} as GrpcFunctionObject),
160+
serverStreamMethod: Object.assign(vi.fn(), {
161+
requestStream: false,
162+
responseStream: true,
163+
originalName: 'serverStreamMethod',
164+
} as GrpcFunctionObject),
165+
bidiStreamMethod: Object.assign(vi.fn(), {
166+
requestStream: true,
167+
responseStream: true,
168+
originalName: 'bidiStreamMethod',
169+
} as GrpcFunctionObject),
170+
};
171+
172+
fillGrpcFunction(mockStub, 'test-service', 'clientStreamMethod');
173+
fillGrpcFunction(mockStub, 'test-service', 'serverStreamMethod');
174+
fillGrpcFunction(mockStub, 'test-service', 'bidiStreamMethod');
175+
176+
expect(mockStartInactiveSpan).not.toHaveBeenCalled();
177+
});
178+
179+
test('does not patch non-function properties', () => {
180+
const mockStub: Stub = {
181+
nonFunction: Object.assign(vi.fn(), {
182+
requestStream: false,
183+
responseStream: false,
184+
originalName: 'nonFunction',
185+
} as GrpcFunctionObject),
186+
};
187+
188+
fillGrpcFunction(mockStub, 'test-service', 'nonFunction');
189+
expect(mockStartInactiveSpan).not.toHaveBeenCalled();
190+
});
191+
192+
test('does not patch methods when return value is not an EventEmitter', () => {
193+
const mockStub: Stub = {
194+
unaryMethod: Object.assign(vi.fn(), {
195+
requestStream: false,
196+
responseStream: false,
197+
originalName: 'unaryMethod',
198+
} as GrpcFunctionObject),
199+
};
200+
201+
(mockStub.unaryMethod as any).apply = vi.fn().mockReturnValue({ notAnEventEmitter: true });
202+
203+
fillGrpcFunction(mockStub, 'test-service', 'unaryMethod');
204+
205+
const result = (mockStub.unaryMethod as GrpcFunction)();
206+
expect(result).toEqual({ notAnEventEmitter: true });
207+
expect(mockStartInactiveSpan).not.toHaveBeenCalled();
208+
});
160209
});
161210
});

yarn.lock

+1-1
Original file line numberDiff line numberDiff line change
@@ -17499,7 +17499,7 @@ google-auth-library@^7.0.0, google-auth-library@^7.0.2:
1749917499
jws "^4.0.0"
1750017500
lru-cache "^6.0.0"
1750117501

17502-
google-gax@^2.9.0, google-gax@^2.9.2:
17502+
google-gax@^2.9.2:
1750317503
version "2.11.2"
1750417504
resolved "https://registry.yarnpkg.com/google-gax/-/google-gax-2.11.2.tgz#9ef7773b94aaa61c4588fb2408d62e8444995026"
1750517505
integrity sha512-PNqXv7Oi5XBMgoMWVxLZHUidfMv7cPHrDSDXqLyEd6kY6pqFnVKC8jt2T1df4JPSc2+VLPdeo6L7X9mbdQG8Xw==

0 commit comments

Comments
 (0)