Skip to content

Commit 0ffd382

Browse files
committed
fix(gax): unify dynamicArgs, preserve outer span context in logging-only mode, and test stream error logs
1 parent 8fa2f51 commit 0ffd382

2 files changed

Lines changed: 239 additions & 35 deletions

File tree

‎core/packages/gax/src/createApiCall.ts‎

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ import {logCall, logNetworkAttempt} from './observability/LoggerHelper';
4545
import {checkLoggingEnabled, checkTracingEnabled} from './observability/util';
4646
import {
4747
StaticTraceContext,
48-
DynamicTraceContext,
4948
AttemptTraceContext,
5049
resolveStaticTraceContext,
5150
} from './observability/contextUtil';
@@ -92,19 +91,12 @@ export function createApiCall(
9291
: undefined;
9392
const serviceName = settings.apiName?.split('.').pop() ?? '';
9493
const isFallback = Boolean(_fallback);
95-
const dynamicArgs: DynamicTraceContext | undefined =
94+
const dynamicArgs: AttemptTraceContext | undefined =
9695
tracingEnabled || loggingEnabled
9796
? {
9897
clientName: serviceName ? `${serviceName}Client` : '',
9998
methodName: settings.otherArgs?.internalMethodName ?? '',
10099
rpcType: isFallback ? 'http' : 'grpc',
101-
}
102-
: undefined;
103-
// Context for per-attempt low level network CLIENT spans.
104-
const attemptDynamicArgs: AttemptTraceContext | undefined =
105-
(tracingEnabled || loggingEnabled) && dynamicArgs
106-
? {
107-
...dynamicArgs,
108100
apiName: settings.apiName ?? '',
109101
}
110102
: undefined;
@@ -114,8 +106,10 @@ export function createApiCall(
114106
callOptions?: CallOptions,
115107
callback?: APICallback,
116108
) => {
117-
// Capture the active client request span context to parent async low level network attempt spans.
118-
const parentContext = tracingEnabled ? context.active() : undefined;
109+
// Capture the active OpenTelemetry context to parent async attempt spans and preserve
110+
// outer span correlation on attempt logs when context does not propagate across async gaps.
111+
const parentContext =
112+
tracingEnabled || loggingEnabled ? context.active() : undefined;
119113
let currentApiCaller = apiCaller;
120114

121115
let thisSettings: CallSettings;
@@ -185,11 +179,7 @@ export function createApiCall(
185179
?.streaming;
186180

187181
// Wrap the transport call so each attempt (initial send and retries) emits a low level network CLIENT span.
188-
if (
189-
(tracingEnabled || loggingEnabled) &&
190-
attemptDynamicArgs &&
191-
staticArgs
192-
) {
182+
if ((tracingEnabled || loggingEnabled) && dynamicArgs && staticArgs) {
193183
const callerWrappedFunc = wrappedFunc;
194184
wrappedFunc = (
195185
argument: {},
@@ -205,18 +195,26 @@ export function createApiCall(
205195
cb,
206196
]);
207197

208-
// If logging is enabled, wrap with L4 attempt error logging
198+
// If logging is enabled, wrap with L4 attempt error logging.
199+
// When tracing is disabled, restore `parentContext` around the attempt so
200+
// `logNetworkAttempt` preserves the caller's outer OpenTelemetry span even
201+
// without an async ContextManager across `funcPromise.then` or retry timers.
209202
if (loggingEnabled) {
210203
const originalAttemptExecution = attemptExecution;
211-
attemptExecution = (cb: APICallback) =>
212-
logNetworkAttempt(
213-
attemptDynamicArgs,
214-
staticArgs,
215-
loggedCallback =>
216-
originalAttemptExecution(loggedCallback ?? cb),
217-
Boolean(streaming),
218-
cb,
219-
);
204+
attemptExecution = (cb: APICallback) => {
205+
const runLoggedAttempt = () =>
206+
logNetworkAttempt(
207+
dynamicArgs,
208+
staticArgs,
209+
loggedCallback =>
210+
originalAttemptExecution(loggedCallback ?? cb),
211+
Boolean(streaming),
212+
cb,
213+
);
214+
return !tracingEnabled && parentContext
215+
? context.with(parentContext, runLoggedAttempt)
216+
: runLoggedAttempt();
217+
};
220218
}
221219

222220
if (tracingEnabled) {
@@ -228,7 +226,7 @@ export function createApiCall(
228226
const originalAttemptExecution = attemptExecution;
229227
attemptExecution = (cb: APICallback) =>
230228
traceAttempt(
231-
{...attemptDynamicArgs, resendCount},
229+
{...dynamicArgs, resendCount},
232230
staticArgs,
233231
tracedAttemptCallback =>
234232
originalAttemptExecution(tracedAttemptCallback ?? cb),
@@ -310,12 +308,7 @@ export function createApiCall(
310308
return currentApiCaller.result(ongoingCall);
311309
};
312310

313-
if (
314-
(tracingEnabled || loggingEnabled) &&
315-
dynamicArgs &&
316-
attemptDynamicArgs &&
317-
staticArgs
318-
) {
311+
if ((tracingEnabled || loggingEnabled) && dynamicArgs && staticArgs) {
319312
const isStreamingCall = apiCaller instanceof StreamingApiCaller;
320313
return (
321314
request: RequestType,
@@ -330,7 +323,7 @@ export function createApiCall(
330323
const originalCallExecution = callExecution;
331324
callExecution = (cb?: APICallback) =>
332325
logCall(
333-
attemptDynamicArgs,
326+
dynamicArgs,
334327
staticArgs,
335328
loggedCallback => originalCallExecution(loggedCallback ?? cb),
336329
isStreamingCall,

‎core/packages/gax/test/unit/apiCallable.ts‎

Lines changed: 212 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,16 @@
1717
import assert from 'assert';
1818
import {PassThrough} from 'stream';
1919
import {status} from '@grpc/grpc-js';
20-
import {SpanKind, SpanStatusCode} from '@opentelemetry/api';
20+
import {
21+
Context,
22+
ContextManager,
23+
ROOT_CONTEXT,
24+
Span,
25+
SpanKind,
26+
SpanStatusCode,
27+
context,
28+
trace,
29+
} from '@opentelemetry/api';
2130
import {GoogleAuth, PassThroughClient} from 'google-auth-library';
2231
import * as loggingUtils from 'google-logging-utils';
2332
import {afterEach, beforeEach, describe, it} from 'mocha';
@@ -402,6 +411,7 @@ describe('createApiCall', () => {
402411
clientName: 'EchoClient',
403412
methodName: 'Echo',
404413
rpcType: 'grpc',
414+
apiName: 'google.example.v1.Echo',
405415
});
406416
assert.deepStrictEqual(staticArgs, {
407417
gcpClientService: 'example',
@@ -468,6 +478,7 @@ describe('createApiCall', () => {
468478
clientName: '',
469479
methodName: '',
470480
rpcType: 'grpc',
481+
apiName: '',
471482
});
472483
});
473484

@@ -2130,6 +2141,206 @@ describe('createApiCall', () => {
21302141
assert.ok(spans.find(s => s.name === 'google.example.v1.Echo/Echo'));
21312142
assert.ok(spans.find(s => s.name === 'EchoClient.Echo'));
21322143
});
2144+
2145+
it('preserves outer OpenTelemetry span correlation across funcPromise and retries in logging-only mode without async context propagation', async () => {
2146+
// Replace OtelHarness's AsyncLocalStorageContextManager with a synchronous
2147+
// stack-only ContextManager so context.active() resets to ROOT_CONTEXT across
2148+
// funcPromise.then(...) microtasks and retry timers.
2149+
harness.teardown();
2150+
class StackOnlyContextManager implements ContextManager {
2151+
private stack: Context[] = [];
2152+
active(): Context {
2153+
return this.stack[this.stack.length - 1] ?? ROOT_CONTEXT;
2154+
}
2155+
with<A extends unknown[], F extends (...args: A) => ReturnType<F>>(
2156+
ctx: Context,
2157+
fn: F,
2158+
thisArg?: ThisParameterType<F>,
2159+
...args: A
2160+
): ReturnType<F> {
2161+
this.stack.push(ctx);
2162+
try {
2163+
return fn.apply(thisArg, args);
2164+
} finally {
2165+
this.stack.pop();
2166+
}
2167+
}
2168+
bind<T>(_ctx: Context, target: T): T {
2169+
return target;
2170+
}
2171+
enable(): this {
2172+
return this;
2173+
}
2174+
disable(): this {
2175+
this.stack = [];
2176+
return this;
2177+
}
2178+
}
2179+
context.setGlobalContextManager(new StackOnlyContextManager().enable());
2180+
trace.setGlobalTracerProvider(harness.provider);
2181+
2182+
let attempts = 0;
2183+
function failingRetriedFunc(
2184+
argument: {},
2185+
metadata: {},
2186+
options: {},
2187+
callback: (err: GoogleError | null, resp?: unknown) => void,
2188+
) {
2189+
attempts++;
2190+
setTimeout(() => {
2191+
const error = new GoogleError(
2192+
attempts === 1 ? 'retryable failure' : 'terminal failure',
2193+
);
2194+
error.code = attempts === 1 ? FAKE_STATUS_CODE_1 : FAKE_STATUS_CODE_2;
2195+
callback(error);
2196+
}, 0);
2197+
return {cancel: () => {}};
2198+
}
2199+
2200+
const settings = new gax.CallSettings({
2201+
apiName: 'google.example.v1.Echo',
2202+
enableTelemetryTracing: false,
2203+
enableTelemetryLogging: true,
2204+
timeout: 0,
2205+
retry: utils.createRetryOptions(1, 1, 1, 0, 0, 0, 500),
2206+
otherArgs: {
2207+
internalMethodName: 'Echo',
2208+
},
2209+
});
2210+
2211+
const apiCall = gaxCreateApiCall(
2212+
Promise.resolve(failingRetriedFunc),
2213+
settings,
2214+
);
2215+
2216+
let outerSpan: Span | undefined;
2217+
const callPromise = trace
2218+
.getTracer('external-app')
2219+
.startActiveSpan('outer-request', span => {
2220+
outerSpan = span;
2221+
const p = apiCall({}, undefined);
2222+
span.end();
2223+
return p;
2224+
});
2225+
2226+
await assert.rejects(async () => {
2227+
await callPromise;
2228+
});
2229+
2230+
assert.ok(outerSpan);
2231+
assert.strictEqual(attempts, 2);
2232+
// 2 L4 DEBUG attempt logs + 1 L3 WARNING call log, all correlated to outerSpan.
2233+
assert.strictEqual(sink.logs.length, 3);
2234+
assert.deepStrictEqual(
2235+
sink.logs.map(l => l.fields.severity),
2236+
['DEBUG', 'DEBUG', 'WARNING'],
2237+
);
2238+
for (const logEntry of sink.logs) {
2239+
assert.strictEqual(
2240+
logEntry.fields.telemetryTraceId,
2241+
outerSpan.spanContext().traceId,
2242+
);
2243+
assert.strictEqual(
2244+
logEntry.fields.telemetrySpanId,
2245+
outerSpan.spanContext().spanId,
2246+
);
2247+
}
2248+
});
2249+
2250+
it('emits one L4 DEBUG log and one L3 WARNING log when a streaming API call emits an error', async () => {
2251+
const settings = new gax.CallSettings({
2252+
apiName: 'google.example.v1.Echo',
2253+
enableTelemetryTracing: true,
2254+
enableTelemetryLogging: true,
2255+
otherArgs: {
2256+
internalMethodName: 'Echo',
2257+
},
2258+
});
2259+
2260+
const streamError = Object.assign(
2261+
new GoogleError('streaming test failure'),
2262+
{
2263+
code: status.UNAVAILABLE,
2264+
},
2265+
);
2266+
const spy = sinon.spy(() => {
2267+
const s = new PassThrough({objectMode: true});
2268+
setImmediate(() => {
2269+
s.emit('error', streamError);
2270+
});
2271+
return Object.assign(s, {cancel: () => {}});
2272+
});
2273+
2274+
const apiCall = gaxCreateApiCall(
2275+
spy as unknown as GRPCCall,
2276+
settings,
2277+
new StreamDescriptor(StreamType.SERVER_STREAMING, true),
2278+
);
2279+
const stream = apiCall({}, undefined) as CancellableStream;
2280+
assert.strictEqual(sink.logs.length, 0);
2281+
2282+
const caught = await new Promise<GoogleError>(resolve => {
2283+
stream.on('error', (err: GoogleError) => resolve(err));
2284+
});
2285+
// Allow StreamProxy / downstream 'error' listeners installed after caller listener (if any) to settle.
2286+
await new Promise<void>(resolve => setImmediate(resolve));
2287+
2288+
assert.strictEqual(caught, streamError);
2289+
assert.strictEqual(sink.logs.length, 2);
2290+
2291+
const debugLogs = sink.logs.filter(l => l.fields.severity === 'DEBUG');
2292+
const warnLogs = sink.logs.filter(l => l.fields.severity === 'WARNING');
2293+
assert.strictEqual(debugLogs.length, 1);
2294+
assert.strictEqual(warnLogs.length, 1);
2295+
2296+
const attemptLog = debugLogs[0];
2297+
const callLog = warnLogs[0];
2298+
assert.deepStrictEqual(attemptLog.args, [
2299+
'RPC attempt failed: %s',
2300+
'streaming test failure',
2301+
]);
2302+
assert.deepStrictEqual(callLog.args, [
2303+
'Client API call failed: %s',
2304+
'streaming test failure',
2305+
]);
2306+
2307+
const attemptOther = attemptLog.fields.other as loggerHelper.LogPayload;
2308+
const callOther = callLog.fields.other as loggerHelper.LogPayload;
2309+
assert.strictEqual(
2310+
attemptOther['rpc.method'],
2311+
'google.example.v1.Echo/Echo',
2312+
);
2313+
assert.strictEqual(
2314+
attemptOther['rpc.response.status_code'],
2315+
'UNAVAILABLE',
2316+
);
2317+
assert.strictEqual('exception.type' in attemptOther, false);
2318+
assert.strictEqual(
2319+
callOther['rpc.method'],
2320+
'google.example.v1.Echo/Echo',
2321+
);
2322+
assert.strictEqual(callOther['rpc.response.status_code'], 'UNAVAILABLE');
2323+
assert.strictEqual(callOther['exception.type'], 'GoogleError');
2324+
assert.strictEqual(
2325+
callOther['exception.message'],
2326+
'streaming test failure',
2327+
);
2328+
2329+
const spans = harness.getSpans('google-gax');
2330+
assert.strictEqual(spans.length, 2);
2331+
const attemptSpan = spans.find(
2332+
s => s.name === 'google.example.v1.Echo/Echo',
2333+
)!;
2334+
const clientRequestSpan = spans.find(s => s.name === 'EchoClient.Echo')!;
2335+
assert.strictEqual(
2336+
attemptLog.fields.telemetrySpanId,
2337+
attemptSpan.spanContext().spanId,
2338+
);
2339+
assert.strictEqual(
2340+
callLog.fields.telemetrySpanId,
2341+
clientRequestSpan.spanContext().spanId,
2342+
);
2343+
});
21332344
});
21342345
});
21352346

0 commit comments

Comments
 (0)