From 403331227cbcd102a3f810b4db82a1d4d6f8fe35 Mon Sep 17 00:00:00 2001 From: Shivanee Persaud Date: Wed, 19 Aug 2026 16:46:06 -0700 Subject: [PATCH 1/3] feat(gax): add TracerHelper and OtelHarness for observability tracing --- core/packages/gax/package.json | 1 + .../gax/src/observability/TracerHelper.ts | 50 +++++ core/packages/gax/test/unit/otelHarness.ts | 198 ++++++++++++++++++ core/packages/gax/test/unit/tracerHelper.ts | 156 ++++++++++++++ 4 files changed, 405 insertions(+) create mode 100644 core/packages/gax/src/observability/TracerHelper.ts create mode 100644 core/packages/gax/test/unit/otelHarness.ts create mode 100644 core/packages/gax/test/unit/tracerHelper.ts diff --git a/core/packages/gax/package.json b/core/packages/gax/package.json index 4a5f7239562..eb3c0c16dea 100644 --- a/core/packages/gax/package.json +++ b/core/packages/gax/package.json @@ -12,6 +12,7 @@ "dependencies": { "@grpc/grpc-js": "^1.12.6", "@grpc/proto-loader": "^0.8.0", + "@opentelemetry/api": "^1.9.0", "duplexify": "^4.1.3", "google-auth-library": "^11.0.0", "google-logging-utils": "^2.0.0", diff --git a/core/packages/gax/src/observability/TracerHelper.ts b/core/packages/gax/src/observability/TracerHelper.ts new file mode 100644 index 00000000000..bc75c0e0281 --- /dev/null +++ b/core/packages/gax/src/observability/TracerHelper.ts @@ -0,0 +1,50 @@ +import { Span, trace, Tracer } from '@opentelemetry/api'; + +export interface StaticTraceContext { + gcpClientService?: string; + gcpVersion?: string; + gcpRepo?: string; + gcpArtifact?: string; +} + +export interface DynamicTraceContext { + clientName: string; + methodName: string; + rpcType: 'grpc' | 'http'; +} + +export function getGaxTracer(): Tracer { + return trace.getTracer('google-gax'); +} + +export async function traceAttempt(dynamicArgs: DynamicTraceContext, staticArgs: StaticTraceContext, fn: () => Promise): Promise { + const spanName = `${dynamicArgs.clientName}.${dynamicArgs.methodName}`; + return getGaxTracer().startActiveSpan(spanName, {}, async (span: Span) => { + span.setAttributes({ + 'gcp.client.service': staticArgs.gcpClientService, + 'gcp.client.version': staticArgs.gcpVersion, + 'gcp.repo': staticArgs.gcpRepo, + 'gcp.artifact': staticArgs.gcpArtifact, + 'gcp.method.name': dynamicArgs.methodName, + 'gcp.method.type': dynamicArgs.rpcType, + }); + + try { + const result = await fn(); + return result; + } catch (e: any) { + span.setAttributes({ + 'error.message': e.message, + 'error.type': e.constructor?.name ?? e.name, + }); + span.recordException(e); + if (e.name) { + span.setAttribute('exception.type', e.name); + } + throw e; + } finally { + span.end(); + } + }); + +} \ No newline at end of file diff --git a/core/packages/gax/test/unit/otelHarness.ts b/core/packages/gax/test/unit/otelHarness.ts new file mode 100644 index 00000000000..084417d13fc --- /dev/null +++ b/core/packages/gax/test/unit/otelHarness.ts @@ -0,0 +1,198 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + trace, + context, + Tracer, + TracerProvider, + Span, + SpanContext, + SpanAttributes, + SpanAttributeValue, + SpanStatus, + Exception, + TimeInput, + TraceFlags, +} from '@opentelemetry/api'; + +export class MockSpan implements Span { + name: string; + attributes: Record = {}; + exceptions: Exception[] = []; + events: Array<{name: string; attributes?: SpanAttributes; time?: TimeInput}> = []; + status?: SpanStatus; + ended = false; + endTime?: TimeInput; + + constructor(name: string) { + this.name = name; + } + + spanContext(): SpanContext { + return { + traceId: '00000000000000000000000000000001', + spanId: '0000000000000001', + traceFlags: TraceFlags.SAMPLED, + }; + } + + setAttribute(key: string, value: SpanAttributeValue): this { + this.attributes[key] = value; + return this; + } + + setAttributes(attributes: SpanAttributes): this { + Object.assign(this.attributes, attributes); + return this; + } + + addEvent( + name: string, + attributesOrStartTime?: SpanAttributes | TimeInput, + startTime?: TimeInput + ): this { + this.events.push({ + name, + attributes: + typeof attributesOrStartTime === 'object' + ? (attributesOrStartTime as SpanAttributes) + : undefined, + time: + typeof attributesOrStartTime === 'number' + ? attributesOrStartTime + : startTime, + }); + return this; + } + + addLink(): this { + return this; + } + + addLinks(): this { + return this; + } + + setStatus(status: SpanStatus): this { + this.status = status; + return this; + } + + updateName(name: string): this { + this.name = name; + return this; + } + + end(endTime?: TimeInput): void { + this.ended = true; + this.endTime = endTime; + } + + isRecording(): boolean { + return !this.ended; + } + + recordException(exception: Exception, _time?: TimeInput): void { + this.exceptions.push(exception); + } +} + +export class MockTracer implements Tracer { + name: string; + version?: string; + readonly spans: MockSpan[] = []; + + constructor(name: string, version?: string) { + this.name = name; + this.version = version; + } + + startSpan(name: string): Span { + const span = new MockSpan(name); + this.spans.push(span); + return span; + } + + startActiveSpan unknown>(name: string, fn: F): ReturnType; + startActiveSpan unknown>( + name: string, + options: any, + fn: F + ): ReturnType; + startActiveSpan unknown>( + name: string, + options: any, + context: any, + fn: F + ): ReturnType; + startActiveSpan unknown>( + name: string, + arg2?: any, + arg3?: any, + arg4?: any + ): ReturnType { + const fn = + typeof arg2 === 'function' + ? arg2 + : typeof arg3 === 'function' + ? arg3 + : arg4; + const span = new MockSpan(name); + this.spans.push(span); + return fn(span); + } +} + +export class OtelHarness implements TracerProvider { + private tracers: Map = new Map(); + + getTracer(name: string, version?: string): Tracer { + const key = `${name}@${version ?? ''}`; + let tracer = this.tracers.get(key); + if (!tracer) { + tracer = new MockTracer(name, version); + this.tracers.set(key, tracer); + } + return tracer; + } + + setup(): void { + trace.setGlobalTracerProvider(this); + } + + teardown(): void { + trace.disable(); + context.disable(); + this.reset(); + } + + reset(): void { + this.tracers.clear(); + } + + getSpans(tracerName = 'google-gax'): MockSpan[] { + const tracer = Array.from(this.tracers.entries()).find(([key]) => + key.startsWith(tracerName) + )?.[1]; + return tracer ? tracer.spans : []; + } + + getLastSpan(tracerName = 'google-gax'): MockSpan | undefined { + const spans = this.getSpans(tracerName); + return spans[spans.length - 1]; + } +} diff --git a/core/packages/gax/test/unit/tracerHelper.ts b/core/packages/gax/test/unit/tracerHelper.ts new file mode 100644 index 00000000000..bcef5790e60 --- /dev/null +++ b/core/packages/gax/test/unit/tracerHelper.ts @@ -0,0 +1,156 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as assert from 'assert'; +import {describe, it, beforeEach, afterEach} from 'mocha'; +import { + getGaxTracer, + traceAttempt, + DynamicTraceContext, + StaticTraceContext, +} from '../../src/observability/TracerHelper'; +import {OtelHarness} from './otelHarness'; + +describe('TracerHelper', () => { + let harness: OtelHarness; + + beforeEach(() => { + harness = new OtelHarness(); + harness.setup(); + }); + + afterEach(() => { + harness.teardown(); + }); + + describe('getGaxTracer', () => { + it('returns a tracer for google-gax', () => { + const tracer = getGaxTracer(); + assert.ok(tracer); + }); + }); + + describe('traceAttempt', () => { + const dynamicArgs: DynamicTraceContext = { + clientName: 'StorageClient', + methodName: 'GetObject', + rpcType: 'grpc', + }; + + const staticArgs: StaticTraceContext = { + gcpClientService: 'storage.googleapis.com', + gcpVersion: '1.2.3', + gcpRepo: 'googleapis/google-cloud-node', + gcpArtifact: '@google-cloud/storage', + }; + + it('creates and ends a span with correct name and attributes on success', async () => { + const expectedResult = {data: 'test'}; + const result = await traceAttempt(dynamicArgs, staticArgs, async () => { + return expectedResult; + }); + + assert.deepStrictEqual(result, expectedResult); + + const spans = harness.getSpans('google-gax'); + assert.strictEqual(spans.length, 1); + + const span = spans[0]; + assert.strictEqual(span.name, 'StorageClient.GetObject'); + assert.strictEqual(span.ended, true); + assert.strictEqual( + span.attributes['gcp.client.service'], + 'storage.googleapis.com' + ); + assert.strictEqual(span.attributes['gcp.client.version'], '1.2.3'); + assert.strictEqual( + span.attributes['gcp.repo'], + 'googleapis/google-cloud-node' + ); + assert.strictEqual( + span.attributes['gcp.artifact'], + '@google-cloud/storage' + ); + assert.strictEqual(span.attributes['gcp.method.name'], 'GetObject'); + assert.strictEqual(span.attributes['gcp.method.type'], 'grpc'); + assert.strictEqual(span.exceptions.length, 0); + }); + + it('records error attributes, exceptions, and rethrows when fn throws an Error', async () => { + const error = new Error('RPC Failed'); + error.name = 'CustomRpcError'; + + await assert.rejects(async () => { + await traceAttempt(dynamicArgs, staticArgs, async () => { + throw error; + }); + }, (err: Error) => { + assert.strictEqual(err.message, 'RPC Failed'); + return true; + }); + + const spans = harness.getSpans('google-gax'); + assert.strictEqual(spans.length, 1); + + const span = spans[0]; + assert.strictEqual(span.name, 'StorageClient.GetObject'); + assert.strictEqual(span.ended, true); + assert.strictEqual(span.attributes['error.message'], 'RPC Failed'); + assert.strictEqual(span.attributes['error.type'], 'Error'); + assert.strictEqual(span.attributes['exception.type'], 'CustomRpcError'); + assert.strictEqual(span.exceptions.length, 1); + assert.strictEqual(span.exceptions[0], error); + }); + + it('handles missing optional static arguments gracefully', async () => { + const emptyStaticArgs: StaticTraceContext = {}; + const result = await traceAttempt(dynamicArgs, emptyStaticArgs, async () => { + return 42; + }); + + assert.strictEqual(result, 42); + + const spans = harness.getSpans('google-gax'); + assert.strictEqual(spans.length, 1); + + const span = spans[0]; + assert.strictEqual(span.name, 'StorageClient.GetObject'); + assert.strictEqual(span.ended, true); + assert.strictEqual(span.attributes['gcp.client.service'], undefined); + assert.strictEqual(span.attributes['gcp.client.version'], undefined); + assert.strictEqual(span.attributes['gcp.repo'], undefined); + assert.strictEqual(span.attributes['gcp.artifact'], undefined); + assert.strictEqual(span.attributes['gcp.method.name'], 'GetObject'); + assert.strictEqual(span.attributes['gcp.method.type'], 'grpc'); + }); + + it('supports http rpcType', async () => { + const httpDynamicArgs: DynamicTraceContext = { + clientName: 'ComputeClient', + methodName: 'InsertInstance', + rpcType: 'http', + }; + + await traceAttempt(httpDynamicArgs, staticArgs, async () => { + return 'ok'; + }); + + const spans = harness.getSpans('google-gax'); + assert.strictEqual(spans.length, 1); + assert.strictEqual(spans[0].attributes['gcp.method.type'], 'http'); + }); + }); +}); From 06615012bdee5eea0faa09f664d5532a2dd1f1eb Mon Sep 17 00:00:00 2001 From: Shivanee Persaud Date: Wed, 19 Aug 2026 17:06:26 -0700 Subject: [PATCH 2/3] test(gax): use standard OpenTelemetry SDK test harness in OtelHarness --- core/packages/gax/package.json | 1 + core/packages/gax/test/unit/otelHarness.ts | 189 +++----------------- core/packages/gax/test/unit/tracerHelper.ts | 10 +- 3 files changed, 37 insertions(+), 163 deletions(-) diff --git a/core/packages/gax/package.json b/core/packages/gax/package.json index eb3c0c16dea..1701f9d81ff 100644 --- a/core/packages/gax/package.json +++ b/core/packages/gax/package.json @@ -24,6 +24,7 @@ }, "devDependencies": { "@babel/plugin-proposal-private-methods": "^7.18.6", + "@opentelemetry/sdk-trace-base": "^2.10.0", "@types/mocha": "^10.0.10", "@types/ncp": "^2.0.8", "@types/node": "^24.0.0", diff --git a/core/packages/gax/test/unit/otelHarness.ts b/core/packages/gax/test/unit/otelHarness.ts index 084417d13fc..7a9a9fafe43 100644 --- a/core/packages/gax/test/unit/otelHarness.ts +++ b/core/packages/gax/test/unit/otelHarness.ts @@ -14,164 +14,27 @@ * limitations under the License. */ +import {trace, context} from '@opentelemetry/api'; import { - trace, - context, - Tracer, - TracerProvider, - Span, - SpanContext, - SpanAttributes, - SpanAttributeValue, - SpanStatus, - Exception, - TimeInput, - TraceFlags, -} from '@opentelemetry/api'; - -export class MockSpan implements Span { - name: string; - attributes: Record = {}; - exceptions: Exception[] = []; - events: Array<{name: string; attributes?: SpanAttributes; time?: TimeInput}> = []; - status?: SpanStatus; - ended = false; - endTime?: TimeInput; - - constructor(name: string) { - this.name = name; - } - - spanContext(): SpanContext { - return { - traceId: '00000000000000000000000000000001', - spanId: '0000000000000001', - traceFlags: TraceFlags.SAMPLED, - }; - } - - setAttribute(key: string, value: SpanAttributeValue): this { - this.attributes[key] = value; - return this; - } - - setAttributes(attributes: SpanAttributes): this { - Object.assign(this.attributes, attributes); - return this; - } - - addEvent( - name: string, - attributesOrStartTime?: SpanAttributes | TimeInput, - startTime?: TimeInput - ): this { - this.events.push({ - name, - attributes: - typeof attributesOrStartTime === 'object' - ? (attributesOrStartTime as SpanAttributes) - : undefined, - time: - typeof attributesOrStartTime === 'number' - ? attributesOrStartTime - : startTime, + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, + ReadableSpan, +} from '@opentelemetry/sdk-trace-base'; + +export class OtelHarness { + readonly exporter: InMemorySpanExporter; + readonly provider: BasicTracerProvider; + + constructor() { + this.exporter = new InMemorySpanExporter(); + this.provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(this.exporter)], }); - return this; - } - - addLink(): this { - return this; - } - - addLinks(): this { - return this; - } - - setStatus(status: SpanStatus): this { - this.status = status; - return this; - } - - updateName(name: string): this { - this.name = name; - return this; - } - - end(endTime?: TimeInput): void { - this.ended = true; - this.endTime = endTime; - } - - isRecording(): boolean { - return !this.ended; - } - - recordException(exception: Exception, _time?: TimeInput): void { - this.exceptions.push(exception); - } -} - -export class MockTracer implements Tracer { - name: string; - version?: string; - readonly spans: MockSpan[] = []; - - constructor(name: string, version?: string) { - this.name = name; - this.version = version; - } - - startSpan(name: string): Span { - const span = new MockSpan(name); - this.spans.push(span); - return span; - } - - startActiveSpan unknown>(name: string, fn: F): ReturnType; - startActiveSpan unknown>( - name: string, - options: any, - fn: F - ): ReturnType; - startActiveSpan unknown>( - name: string, - options: any, - context: any, - fn: F - ): ReturnType; - startActiveSpan unknown>( - name: string, - arg2?: any, - arg3?: any, - arg4?: any - ): ReturnType { - const fn = - typeof arg2 === 'function' - ? arg2 - : typeof arg3 === 'function' - ? arg3 - : arg4; - const span = new MockSpan(name); - this.spans.push(span); - return fn(span); - } -} - -export class OtelHarness implements TracerProvider { - private tracers: Map = new Map(); - - getTracer(name: string, version?: string): Tracer { - const key = `${name}@${version ?? ''}`; - let tracer = this.tracers.get(key); - if (!tracer) { - tracer = new MockTracer(name, version); - this.tracers.set(key, tracer); - } - return tracer; } setup(): void { - trace.setGlobalTracerProvider(this); + trace.setGlobalTracerProvider(this.provider); } teardown(): void { @@ -181,18 +44,24 @@ export class OtelHarness implements TracerProvider { } reset(): void { - this.tracers.clear(); + this.exporter.reset(); } - getSpans(tracerName = 'google-gax'): MockSpan[] { - const tracer = Array.from(this.tracers.entries()).find(([key]) => - key.startsWith(tracerName) - )?.[1]; - return tracer ? tracer.spans : []; + getSpans(tracerName?: string): ReadableSpan[] { + const spans = this.exporter.getFinishedSpans(); + if (tracerName) { + return spans.filter( + span => + span.instrumentationScope?.name?.startsWith(tracerName) || + (span as unknown as {instrumentationLibrary?: {name?: string}}).instrumentationLibrary?.name?.startsWith(tracerName) + ); + } + return spans; } - getLastSpan(tracerName = 'google-gax'): MockSpan | undefined { + getLastSpan(tracerName?: string): ReadableSpan | undefined { const spans = this.getSpans(tracerName); return spans[spans.length - 1]; } } + diff --git a/core/packages/gax/test/unit/tracerHelper.ts b/core/packages/gax/test/unit/tracerHelper.ts index bcef5790e60..a14a991c555 100644 --- a/core/packages/gax/test/unit/tracerHelper.ts +++ b/core/packages/gax/test/unit/tracerHelper.ts @@ -86,7 +86,7 @@ describe('TracerHelper', () => { ); assert.strictEqual(span.attributes['gcp.method.name'], 'GetObject'); assert.strictEqual(span.attributes['gcp.method.type'], 'grpc'); - assert.strictEqual(span.exceptions.length, 0); + assert.strictEqual(span.events.length, 0); }); it('records error attributes, exceptions, and rethrows when fn throws an Error', async () => { @@ -111,8 +111,12 @@ describe('TracerHelper', () => { assert.strictEqual(span.attributes['error.message'], 'RPC Failed'); assert.strictEqual(span.attributes['error.type'], 'Error'); assert.strictEqual(span.attributes['exception.type'], 'CustomRpcError'); - assert.strictEqual(span.exceptions.length, 1); - assert.strictEqual(span.exceptions[0], error); + assert.strictEqual(span.events.length, 1); + assert.strictEqual(span.events[0].name, 'exception'); + assert.strictEqual( + span.events[0].attributes?.['exception.message'], + 'RPC Failed' + ); }); it('handles missing optional static arguments gracefully', async () => { From c4c4c5fa374f1c5e44c3d2e6488fab56f45d40c9 Mon Sep 17 00:00:00 2001 From: Shivanee Persaud Date: Wed, 19 Aug 2026 17:08:02 -0700 Subject: [PATCH 3/3] chore(gax): add license header to TracerHelper.ts --- .../gax/src/observability/TracerHelper.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/core/packages/gax/src/observability/TracerHelper.ts b/core/packages/gax/src/observability/TracerHelper.ts index bc75c0e0281..5958fe0d6a5 100644 --- a/core/packages/gax/src/observability/TracerHelper.ts +++ b/core/packages/gax/src/observability/TracerHelper.ts @@ -1,3 +1,19 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { Span, trace, Tracer } from '@opentelemetry/api'; export interface StaticTraceContext {