diff --git a/packages/grpc-js/src/call.ts b/packages/grpc-js/src/call.ts index 426deb6a6..1b441935f 100644 --- a/packages/grpc-js/src/call.ts +++ b/packages/grpc-js/src/call.ts @@ -18,13 +18,13 @@ import { EventEmitter } from 'events'; import { Duplex, Readable, Writable } from 'stream'; -import { StatusObject, MessageContext } from './call-interface'; +import { AuthContext } from './auth-context'; +import { MessageContext, StatusObject } from './call-interface'; +import { InterceptingCallInterface } from './client-interceptors'; import { Status } from './constants'; import { EmitterAugmentation1 } from './events'; import { Metadata } from './metadata'; import { ObjectReadable, ObjectWritable, WriteCallback } from './object-stream'; -import { InterceptingCallInterface } from './client-interceptors'; -import { AuthContext } from './auth-context'; /** * A type extending the built-in Error object with additional fields. @@ -83,7 +83,7 @@ export function callErrorFromStatus( const message = `${status.code} ${Status[status.code]}: ${status.details}`; const error = new Error(message); const stack = `${error.stack}\nfor call at\n${callerStack}`; - return Object.assign(new Error(message), status, { stack }); + return Object.assign(error, status, { stack }); } export class ClientUnaryCallImpl diff --git a/packages/grpc-js/src/channel-options.ts b/packages/grpc-js/src/channel-options.ts index f632d8f45..0c9fe8404 100644 --- a/packages/grpc-js/src/channel-options.ts +++ b/packages/grpc-js/src/channel-options.ts @@ -67,6 +67,12 @@ export interface ChannelOptions { 'grpc-node.retry_max_attempts_limit'?: number; 'grpc-node.flow_control_window'?: number; 'grpc.server_call_metric_recording'?: number; + /** + * Whether to construct an Error object on every call to capture the caller's + * stack trace. Enabled by default (1). Can be set to 0 to disable and avoid + * stack trace capture overhead for every RPC. + */ + 'grpc.enable_caller_stack_traces'?: number; // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any; } @@ -105,7 +111,8 @@ export const recognizedOptions = { 'grpc.lb.ring_hash.ring_size_cap': true, 'grpc-node.retry_max_attempts_limit': true, 'grpc-node.flow_control_window': true, - 'grpc.server_call_metric_recording': true + 'grpc.server_call_metric_recording': true, + 'grpc.enable_caller_stack_traces': true, }; export function channelOptionsEqual( diff --git a/packages/grpc-js/src/client.ts b/packages/grpc-js/src/client.ts index dc75ac482..9b7640da1 100644 --- a/packages/grpc-js/src/client.ts +++ b/packages/grpc-js/src/client.ts @@ -16,6 +16,7 @@ */ import { + callErrorFromStatus, ClientDuplexStream, ClientDuplexStreamImpl, ClientReadableStream, @@ -25,37 +26,37 @@ import { ClientWritableStream, ClientWritableStreamImpl, ServiceError, - callErrorFromStatus, SurfaceCall, } from './call'; import { CallCredentials } from './call-credentials'; import { StatusObject } from './call-interface'; import { Channel, ChannelImplementation } from './channel'; -import { ConnectivityState } from './connectivity-state'; import { ChannelCredentials } from './channel-credentials'; import { ChannelOptions } from './channel-options'; -import { Status } from './constants'; -import { Metadata } from './metadata'; -import { ClientMethodDefinition } from './make-client'; import { getInterceptingCall, + InterceptingCallInterface, Interceptor, - InterceptorProvider, InterceptorArguments, - InterceptingCallInterface, + InterceptorProvider, } from './client-interceptors'; +import { ConnectivityState } from './connectivity-state'; +import { Status } from './constants'; +import { Deadline } from './deadline'; +import { ClientMethodDefinition } from './make-client'; +import { Metadata } from './metadata'; import { - ServerUnaryCall, + ServerDuplexStream, ServerReadableStream, + ServerUnaryCall, ServerWritableStream, - ServerDuplexStream, } from './server-call'; -import { Deadline } from './deadline'; const CHANNEL_SYMBOL = Symbol(); const INTERCEPTOR_SYMBOL = Symbol(); const INTERCEPTOR_PROVIDER_SYMBOL = Symbol(); const CALL_INVOCATION_TRANSFORMER_SYMBOL = Symbol(); +const ENABLE_CALLER_STACK_TRACES_SYMBOL = Symbol(); function isFunction( arg: Metadata | CallOptions | UnaryCallback | undefined @@ -109,8 +110,10 @@ export type ClientOptions = Partial & { callInvocationTransformer?: CallInvocationTransformer; }; -function getErrorStackString(error: Error): string { - return error.stack?.split('\n').slice(1).join('\n') || 'no stack trace available'; +function getErrorStackString(error: Error | null): string { + return ( + error?.stack?.split('\n').slice(1).join('\n') || 'no stack trace available' + ); } /** @@ -122,12 +125,16 @@ export class Client { private readonly [INTERCEPTOR_SYMBOL]: Interceptor[]; private readonly [INTERCEPTOR_PROVIDER_SYMBOL]: InterceptorProvider[]; private readonly [CALL_INVOCATION_TRANSFORMER_SYMBOL]?: CallInvocationTransformer; + private readonly [ENABLE_CALLER_STACK_TRACES_SYMBOL]: boolean; constructor( address: string, credentials: ChannelCredentials, options: ClientOptions = {} ) { options = Object.assign({}, options); + this[ENABLE_CALLER_STACK_TRACES_SYMBOL] = + options['grpc.enable_caller_stack_traces'] !== 0 && + (options['grpc.enable_caller_stack_traces'] as any) !== false; this[INTERCEPTOR_SYMBOL] = options.interceptors ?? []; delete options.interceptors; this[INTERCEPTOR_PROVIDER_SYMBOL] = options.interceptor_providers ?? []; @@ -322,7 +329,9 @@ export class Client { emitter.call = call; let responseMessage: ResponseType | null = null; let receivedStatus = false; - let callerStackError: Error | null = new Error(); + let callerStackError: Error | null = this[ENABLE_CALLER_STACK_TRACES_SYMBOL] + ? new Error() + : null; call.start(callProperties.metadata, { onReceiveMetadata: metadata => { emitter.emit('metadata', metadata); @@ -330,7 +339,10 @@ export class Client { // eslint-disable-next-line @typescript-eslint/no-explicit-any onReceiveMessage(message: any) { if (responseMessage !== null) { - call.cancelWithStatus(Status.UNIMPLEMENTED, 'Too many responses received'); + call.cancelWithStatus( + Status.UNIMPLEMENTED, + 'Too many responses received' + ); } responseMessage = message; }, @@ -341,7 +353,7 @@ export class Client { receivedStatus = true; if (status.code === Status.OK) { if (responseMessage === null) { - const callerStack = getErrorStackString(callerStackError!); + const callerStack = getErrorStackString(callerStackError); callProperties.callback!( callErrorFromStatus( { @@ -356,7 +368,7 @@ export class Client { callProperties.callback!(null, responseMessage); } } else { - const callerStack = getErrorStackString(callerStackError!); + const callerStack = getErrorStackString(callerStackError); callProperties.callback!(callErrorFromStatus(status, callerStack)); } /* Avoid retaining the callerStackError object in the call context of @@ -455,7 +467,9 @@ export class Client { emitter.call = call; let responseMessage: ResponseType | null = null; let receivedStatus = false; - let callerStackError: Error | null = new Error(); + let callerStackError: Error | null = this[ENABLE_CALLER_STACK_TRACES_SYMBOL] + ? new Error() + : null; call.start(callProperties.metadata, { onReceiveMetadata: metadata => { emitter.emit('metadata', metadata); @@ -463,7 +477,10 @@ export class Client { // eslint-disable-next-line @typescript-eslint/no-explicit-any onReceiveMessage(message: any) { if (responseMessage !== null) { - call.cancelWithStatus(Status.UNIMPLEMENTED, 'Too many responses received'); + call.cancelWithStatus( + Status.UNIMPLEMENTED, + 'Too many responses received' + ); } responseMessage = message; call.startRead(); @@ -475,7 +492,7 @@ export class Client { receivedStatus = true; if (status.code === Status.OK) { if (responseMessage === null) { - const callerStack = getErrorStackString(callerStackError!); + const callerStack = getErrorStackString(callerStackError); callProperties.callback!( callErrorFromStatus( { @@ -490,7 +507,7 @@ export class Client { callProperties.callback!(null, responseMessage); } } else { - const callerStack = getErrorStackString(callerStackError!); + const callerStack = getErrorStackString(callerStackError); callProperties.callback!(callErrorFromStatus(status, callerStack)); } /* Avoid retaining the callerStackError object in the call context of @@ -592,7 +609,9 @@ export class Client { * call after that. */ stream.call = call; let receivedStatus = false; - let callerStackError: Error | null = new Error(); + let callerStackError: Error | null = this[ENABLE_CALLER_STACK_TRACES_SYMBOL] + ? new Error() + : null; call.start(callProperties.metadata, { onReceiveMetadata(metadata: Metadata) { stream.emit('metadata', metadata); @@ -608,7 +627,7 @@ export class Client { receivedStatus = true; stream.push(null); if (status.code !== Status.OK) { - const callerStack = getErrorStackString(callerStackError!); + const callerStack = getErrorStackString(callerStackError); stream.emit('error', callErrorFromStatus(status, callerStack)); } /* Avoid retaining the callerStackError object in the call context of @@ -687,7 +706,9 @@ export class Client { * call after that. */ stream.call = call; let receivedStatus = false; - let callerStackError: Error | null = new Error(); + let callerStackError: Error | null = this[ENABLE_CALLER_STACK_TRACES_SYMBOL] + ? new Error() + : null; call.start(callProperties.metadata, { onReceiveMetadata(metadata: Metadata) { stream.emit('metadata', metadata); @@ -702,7 +723,7 @@ export class Client { receivedStatus = true; stream.push(null); if (status.code !== Status.OK) { - const callerStack = getErrorStackString(callerStackError!); + const callerStack = getErrorStackString(callerStackError); stream.emit('error', callErrorFromStatus(status, callerStack)); } /* Avoid retaining the callerStackError object in the call context of diff --git a/packages/grpc-js/test/test-client.ts b/packages/grpc-js/test/test-client.ts index bbb3f063f..e4771b682 100644 --- a/packages/grpc-js/test/test-client.ts +++ b/packages/grpc-js/test/test-client.ts @@ -18,8 +18,9 @@ import * as assert from 'assert'; import * as grpc from '../src'; -import { Server, ServerCredentials } from '../src'; -import { Client } from '../src'; +import { Client, Server, ServerCredentials } from '../src'; +import { callErrorFromStatus } from '../src/call'; +import { recognizedOptions } from '../src/channel-options'; import { ConnectivityState } from '../src/connectivity-state'; const clientInsecureCreds = grpc.credentials.createInsecure(); @@ -164,3 +165,238 @@ describe('Client with a nonexistent target domain', () => { client.close(); }); }); + +describe('Client caller stack traces opt-out', () => { + it('should have grpc.enable_caller_stack_traces in recognizedOptions', () => { + assert.strictEqual( + recognizedOptions['grpc.enable_caller_stack_traces'], + true + ); + }); + + describe('callErrorFromStatus', () => { + it('should construct a ServiceError with status and caller stack', () => { + const status = { + code: grpc.status.INTERNAL, + details: 'Internal error details', + metadata: new grpc.Metadata(), + }; + const error = callErrorFromStatus(status, 'test-caller-stack'); + assert.strictEqual(error.code, grpc.status.INTERNAL); + assert.strictEqual(error.details, 'Internal error details'); + assert.strictEqual(error.metadata, status.metadata); + assert.ok(error.stack); + assert.ok(error.stack.includes('for call at\ntest-caller-stack')); + }); + }); + + describe('unary calls', () => { + it('should include caller stack trace by default', done => { + const client = new Client('localhost:1', clientInsecureCreds); + client.makeUnaryRequest( + '/service/method', + x => x, + x => x, + Buffer.from([]), + error => { + assert(error); + assert.strictEqual(error?.code, grpc.status.UNAVAILABLE); + assert.ok(error?.stack?.includes('for call at')); + assert.ok( + !error?.stack?.includes('for call at\nno stack trace available') + ); + client.close(); + done(); + } + ); + }); + + it('should include caller stack trace when explicitly enabled', done => { + const client = new Client('localhost:1', clientInsecureCreds, { + 'grpc.enable_caller_stack_traces': 1, + }); + client.makeUnaryRequest( + '/service/method', + x => x, + x => x, + Buffer.from([]), + error => { + assert(error); + assert.strictEqual(error?.code, grpc.status.UNAVAILABLE); + assert.ok(error?.stack?.includes('for call at')); + assert.ok( + !error?.stack?.includes('for call at\nno stack trace available') + ); + client.close(); + done(); + } + ); + }); + + it('should omit caller stack trace when disabled with 0', done => { + const client = new Client('localhost:1', clientInsecureCreds, { + 'grpc.enable_caller_stack_traces': 0, + }); + client.makeUnaryRequest( + '/service/method', + x => x, + x => x, + Buffer.from([]), + error => { + assert(error); + assert.strictEqual(error?.code, grpc.status.UNAVAILABLE); + assert.ok( + error?.stack?.includes('for call at\nno stack trace available') + ); + client.close(); + done(); + } + ); + }); + + it('should omit caller stack trace when disabled with false', done => { + const client = new Client('localhost:1', clientInsecureCreds, { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'grpc.enable_caller_stack_traces': false as any, + }); + client.makeUnaryRequest( + '/service/method', + x => x, + x => x, + Buffer.from([]), + error => { + assert(error); + assert.strictEqual(error?.code, grpc.status.UNAVAILABLE); + assert.ok( + error?.stack?.includes('for call at\nno stack trace available') + ); + client.close(); + done(); + } + ); + }); + }); + + describe('streaming calls', () => { + it('should include caller stack trace on client-streaming error by default', done => { + const client = new Client('localhost:1', clientInsecureCreds); + const stream = client.makeClientStreamRequest( + '/service/method', + (x: Buffer) => x, + x => x, + error => { + assert(error); + assert.strictEqual(error?.code, grpc.status.UNAVAILABLE); + assert.ok(error?.stack?.includes('for call at')); + assert.ok( + !error?.stack?.includes('for call at\nno stack trace available') + ); + client.close(); + done(); + } + ); + stream.write(Buffer.from([])); + }); + + it('should omit caller stack trace on client-streaming error when disabled', done => { + const client = new Client('localhost:1', clientInsecureCreds, { + 'grpc.enable_caller_stack_traces': 0, + }); + const stream = client.makeClientStreamRequest( + '/service/method', + (x: Buffer) => x, + x => x, + error => { + assert(error); + assert.strictEqual(error?.code, grpc.status.UNAVAILABLE); + assert.ok( + error?.stack?.includes('for call at\nno stack trace available') + ); + client.close(); + done(); + } + ); + stream.write(Buffer.from([])); + }); + + it('should include caller stack trace on server-streaming error by default', done => { + const client = new Client('localhost:1', clientInsecureCreds); + const stream = client.makeServerStreamRequest( + '/service/method', + x => x, + x => x, + Buffer.from([]) + ); + stream.on('error', (error: grpc.ServiceError) => { + assert(error); + assert.strictEqual(error.code, grpc.status.UNAVAILABLE); + assert.ok(error.stack?.includes('for call at')); + assert.ok( + !error.stack?.includes('for call at\nno stack trace available') + ); + client.close(); + done(); + }); + }); + + it('should omit caller stack trace on server-streaming error when disabled', done => { + const client = new Client('localhost:1', clientInsecureCreds, { + 'grpc.enable_caller_stack_traces': 0, + }); + const stream = client.makeServerStreamRequest( + '/service/method', + x => x, + x => x, + Buffer.from([]) + ); + stream.on('error', (error: grpc.ServiceError) => { + assert(error); + assert.strictEqual(error.code, grpc.status.UNAVAILABLE); + assert.ok( + error.stack?.includes('for call at\nno stack trace available') + ); + client.close(); + done(); + }); + }); + + it('should include caller stack trace on bidi-streaming error by default', done => { + const client = new Client('localhost:1', clientInsecureCreds); + const stream = client.makeBidiStreamRequest( + '/service/method', + (x: Buffer) => x, + x => x + ); + stream.on('error', (error: grpc.ServiceError) => { + assert(error); + assert.strictEqual(error.code, grpc.status.UNAVAILABLE); + assert.ok(error.stack?.includes('for call at')); + assert.ok( + !error.stack?.includes('for call at\nno stack trace available') + ); + client.close(); + done(); + }); + }); + + it('should omit caller stack trace on bidi-streaming error when disabled', done => { + const client = new Client('localhost:1', clientInsecureCreds, { + 'grpc.enable_caller_stack_traces': 0, + }); + const stream = client.makeBidiStreamRequest( + '/service/method', + (x: Buffer) => x, + x => x + ); + stream.on('error', (error: grpc.ServiceError) => { + assert(error); + assert.strictEqual(error.code, grpc.status.UNAVAILABLE); + assert.ok( + error.stack?.includes('for call at\nno stack trace available') + ); + client.close(); + done(); + }); + }); + }); +});