diff --git a/core/packages/gax/package.json b/core/packages/gax/package.json index 4a5f7239562..1701f9d81ff 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", @@ -23,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/src/observability/TracerHelper.ts b/core/packages/gax/src/observability/TracerHelper.ts new file mode 100644 index 00000000000..5958fe0d6a5 --- /dev/null +++ b/core/packages/gax/src/observability/TracerHelper.ts @@ -0,0 +1,66 @@ +/** + * 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 { + 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..7a9a9fafe43 --- /dev/null +++ b/core/packages/gax/test/unit/otelHarness.ts @@ -0,0 +1,67 @@ +/** + * 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} from '@opentelemetry/api'; +import { + 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)], + }); + } + + setup(): void { + trace.setGlobalTracerProvider(this.provider); + } + + teardown(): void { + trace.disable(); + context.disable(); + this.reset(); + } + + reset(): void { + this.exporter.reset(); + } + + 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?: 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 new file mode 100644 index 00000000000..a14a991c555 --- /dev/null +++ b/core/packages/gax/test/unit/tracerHelper.ts @@ -0,0 +1,160 @@ +/** + * 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.events.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.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 () => { + 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'); + }); + }); +});