Skip to content

Commit 39becf7

Browse files
committed
feat(gax): add logNetworkAttempt to LoggerHelper
1 parent 2009773 commit 39becf7

2 files changed

Lines changed: 264 additions & 36 deletions

File tree

‎core/packages/gax/src/observability/LoggerHelper.ts‎

Lines changed: 119 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
resolveExceptionMessage,
3434
resolveExceptionStacktrace,
3535
resolveExceptionType,
36+
resolveResponsePayload,
3637
resolveResponseStatusCode,
3738
} from './errorUtil';
3839
import {getPromiseTarget, handlePromise, handleStream} from './TracerHelper';
@@ -57,6 +58,22 @@ export interface LogPayload {
5758
'exception.stacktrace'?: string;
5859
}
5960

61+
/**
62+
* Resolves a `LogSeverity` value across `google-logging-utils` versions where
63+
* `LogSeverity` may be exported as a type only.
64+
*/
65+
function resolveLogSeverity(
66+
level: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR',
67+
): loggingUtils.LogSeverity {
68+
return (
69+
(
70+
loggingUtils as {
71+
LogSeverity?: Record<string, loggingUtils.LogSeverity>;
72+
}
73+
).LogSeverity?.[level] ?? (level as loggingUtils.LogSeverity)
74+
);
75+
}
76+
6077
/**
6178
* Returns the `google-logging-utils` logger for the given GCP client service,
6279
* defaulting to the `'google-gax'` namespace when no service name is provided.
@@ -125,6 +142,48 @@ export function buildLogAttributes(
125142
return attributes;
126143
}
127144

145+
/**
146+
* Executes `fn` and attaches stream, promise, callback, or synchronous error
147+
* handlers to invoke `onError` when the operation fails.
148+
*/
149+
function executeLoggedOperation<T>(
150+
fn: (loggedCallback?: APICallback) => T,
151+
onError: (err: unknown) => void,
152+
isStreamCall: boolean,
153+
userCallback?: APICallback,
154+
): T {
155+
// Wrap user callback to log RPC errors before forwarding.
156+
let callbackInvoked = false;
157+
const loggedCallback: APICallback | undefined = userCallback
158+
? function (this: unknown, ...args: Parameters<APICallback>) {
159+
callbackInvoked = true;
160+
const err = args[0];
161+
if (err) {
162+
onError(err);
163+
}
164+
userCallback.apply(this, args);
165+
}
166+
: undefined;
167+
168+
// Execute call and attach error handlers for streams, promises, or sync throws.
169+
try {
170+
const result = fn(loggedCallback);
171+
const promiseTarget = !isStreamCall ? getPromiseTarget(result) : null;
172+
if (isStreamCall && result instanceof EventEmitter) {
173+
handleStream(result, onError, () => {}, Boolean(loggedCallback));
174+
} else if (promiseTarget) {
175+
handlePromise(promiseTarget, onError, () => {});
176+
}
177+
return result;
178+
} catch (syncErr) {
179+
// Avoid logging errors thrown inside userCallback after RPC completion.
180+
if (!callbackInvoked) {
181+
onError(syncErr);
182+
}
183+
throw syncErr;
184+
}
185+
}
186+
128187
/**
129188
* Executes an API call and emits a single `WARNING` log entry if the logical
130189
* request fails across promise, callback, stream, or synchronous execution boundaries.
@@ -179,17 +238,10 @@ export function logCall<T = GaxCallResult>(
179238
...(stacktrace !== undefined ? {'exception.stacktrace': stacktrace} : {}),
180239
};
181240

182-
const warningSeverity =
183-
(
184-
loggingUtils as {
185-
LogSeverity?: {WARNING: loggingUtils.LogSeverity};
186-
}
187-
).LogSeverity?.WARNING ?? ('WARNING' as loggingUtils.LogSeverity);
188-
189241
// Emit structured WARNING log.
190242
logger(
191243
{
192-
severity: warningSeverity,
244+
severity: resolveLogSeverity('WARNING'),
193245
...(traceId ? {telemetryTraceId: traceId} : {}),
194246
...(spanId ? {telemetrySpanId: spanId} : {}),
195247
other: attributes,
@@ -199,34 +251,65 @@ export function logCall<T = GaxCallResult>(
199251
);
200252
};
201253

202-
// Wrap user callback to log RPC errors before forwarding.
203-
let callbackInvoked = false;
204-
const loggedCallback: APICallback | undefined = userCallback
205-
? function (this: unknown, ...args: Parameters<APICallback>) {
206-
callbackInvoked = true;
207-
const err = args[0];
208-
if (err) {
209-
emitL3WarnLog(err);
210-
}
211-
userCallback.apply(this, args);
212-
}
213-
: undefined;
254+
return executeLoggedOperation(fn, emitL3WarnLog, isStreamCall, userCallback);
255+
}
214256

215-
// Execute call and attach error handlers for streams, promises, or sync throws.
216-
try {
217-
const result = fn(loggedCallback);
218-
const promiseTarget = !isStreamCall ? getPromiseTarget(result) : null;
219-
if (isStreamCall && result instanceof EventEmitter) {
220-
handleStream(result, emitL3WarnLog, () => {}, Boolean(loggedCallback));
221-
} else if (promiseTarget) {
222-
handlePromise(promiseTarget, emitL3WarnLog, () => {});
223-
}
224-
return result;
225-
} catch (syncErr) {
226-
// Avoid logging errors thrown inside userCallback after RPC completion.
227-
if (!callbackInvoked) {
228-
emitL3WarnLog(syncErr);
257+
/**
258+
* Executes an individual RPC transport attempt and emits a `DEBUG` log entry
259+
* if the attempt fails across promise, callback, stream, or synchronous execution boundaries.
260+
*
261+
* @template T
262+
* @param {AttemptTraceContext} attemptDynamicArgs - Dynamic context for the RPC attempt.
263+
* @param {StaticTraceContext} staticArgs - Static context for the client library.
264+
* @param {function} fn - The transport attempt operation to execute. Receives the wrapped
265+
* callback when `userCallback` is supplied, otherwise `undefined`.
266+
* @param {boolean} [isStreamCall=false] - Whether the operation returns a stream (`EventEmitter`).
267+
* @param {APICallback} [userCallback] - Optional attempt callback.
268+
* @returns {T} The return value of `fn`.
269+
*/
270+
export function logNetworkAttempt<T = GaxCallResult>(
271+
attemptDynamicArgs: AttemptTraceContext,
272+
staticArgs: StaticTraceContext,
273+
fn: (loggedCallback?: APICallback) => T,
274+
isStreamCall = false,
275+
userCallback?: APICallback,
276+
): T {
277+
// Snapshot attempt context in case async rejection loses active context.
278+
const initialContext = context.active();
279+
280+
const emitL4DebugLog = (err: unknown) => {
281+
// Resolve logger and active T4 attempt span context for this attempt.
282+
const logger = getGaxLogger(staticArgs?.gcpClientService);
283+
const attemptSpan =
284+
getActiveTraceContext()?.attemptSpan ??
285+
getActiveTraceContext(initialContext)?.attemptSpan;
286+
const spanContext = attemptSpan?.spanContext();
287+
const traceId = spanContext?.traceId;
288+
const spanId = spanContext?.spanId;
289+
290+
// Build structured log attributes and attach attempt metadata payload.
291+
const attributes = buildLogAttributes(err, attemptDynamicArgs, staticArgs);
292+
const responsePayload = resolveResponsePayload(
293+
(err as {metadata?: unknown} | null | undefined)?.metadata,
294+
);
295+
if (responsePayload !== undefined) {
296+
attributes['response.payload'] = responsePayload;
229297
}
230-
throw syncErr;
231-
}
298+
299+
// Emit structured DEBUG log for the failed attempt.
300+
const message = resolveExceptionMessage(err, attemptDynamicArgs.rpcType);
301+
logger(
302+
{
303+
severity: resolveLogSeverity('DEBUG'),
304+
...(traceId ? {telemetryTraceId: traceId} : {}),
305+
...(spanId ? {telemetrySpanId: spanId} : {}),
306+
other: attributes,
307+
},
308+
'RPC attempt failed: %s',
309+
message || err,
310+
);
311+
};
312+
313+
// Intercept and dispatch execution boundaries for the attempt.
314+
return executeLoggedOperation(fn, emitL4DebugLog, isStreamCall, userCallback);
232315
}

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

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
buildLogAttributes,
2424
getGaxLogger,
2525
logCall,
26+
logNetworkAttempt,
2627
LogPayload,
2728
} from '../../src/observability/LoggerHelper';
2829
import {traceAttempt, traceCall} from '../../src/observability/TracerHelper';
@@ -482,4 +483,148 @@ describe('LoggerHelper', () => {
482483
);
483484
});
484485
});
486+
487+
describe('logNetworkAttempt', () => {
488+
it('does not log when an attempt succeeds across promise, callback, stream, or sync boundaries', async () => {
489+
const asyncResult = await logNetworkAttempt(
490+
dynamicArgs,
491+
staticArgs,
492+
async () => ({ok: true}),
493+
);
494+
assert.deepStrictEqual(asyncResult, {ok: true});
495+
496+
const syncResult = logNetworkAttempt(dynamicArgs, staticArgs, () => 42);
497+
assert.strictEqual(syncResult, 42);
498+
499+
let callbackResult: unknown;
500+
logNetworkAttempt(
501+
dynamicArgs,
502+
staticArgs,
503+
loggedCallback => {
504+
loggedCallback!(null, {cb: true});
505+
return undefined as unknown as ResultTuple;
506+
},
507+
false,
508+
(_err, res) => {
509+
callbackResult = res;
510+
},
511+
);
512+
assert.deepStrictEqual(callbackResult, {cb: true});
513+
514+
const stream = new EventEmitter();
515+
logNetworkAttempt(dynamicArgs, staticArgs, () => stream, true);
516+
stream.emit('end');
517+
518+
assert.strictEqual(sink.logs.length, 0);
519+
});
520+
521+
it('emits a DEBUG log with response.payload when err.metadata is present and omits it when absent', async () => {
522+
const errWithMeta = Object.assign(new GoogleError('Unavailable'), {
523+
code: Status.UNAVAILABLE,
524+
metadata: {'x-goog-request-id': 'req-123'},
525+
});
526+
527+
await assert.rejects(
528+
async () => {
529+
await logNetworkAttempt(dynamicArgs, staticArgs, async () => {
530+
throw errWithMeta;
531+
});
532+
},
533+
(caught: unknown) => caught === errWithMeta,
534+
);
535+
536+
assert.strictEqual(sink.logs.length, 1);
537+
const entry = sink.logs[0];
538+
assert.strictEqual(entry.namespace, 'storage');
539+
assert.strictEqual(entry.fields.severity, 'DEBUG');
540+
assert.deepStrictEqual(entry.args, [
541+
'RPC attempt failed: %s',
542+
'Unavailable',
543+
]);
544+
const other = entry.fields.other as LogPayload;
545+
assert.deepStrictEqual(other, {
546+
'gcp.client.service': 'storage',
547+
'gcp.client.version': '1.2.3',
548+
'gcp.client.repo': DEFAULT_GCP_REPO,
549+
'gcp.client.artifact': '@google-cloud/storage',
550+
'gcp.client.language': 'node',
551+
'rpc.system.name': 'grpc',
552+
'rpc.method': 'google.storage.v2.Storage/GetObject',
553+
'url.domain': 'storage.googleapis.com',
554+
'rpc.response.status_code': 'UNAVAILABLE',
555+
'error.type': 'UNAVAILABLE',
556+
'response.payload': '{"x-goog-request-id":"req-123"}',
557+
});
558+
assert.strictEqual('exception.type' in other, false);
559+
assert.strictEqual('exception.message' in other, false);
560+
assert.strictEqual('exception.stacktrace' in other, false);
561+
562+
sink.reset();
563+
const errWithoutMeta = new TypeError('client error');
564+
assert.throws(
565+
() =>
566+
logNetworkAttempt(dynamicArgs, staticArgs, () => {
567+
throw errWithoutMeta;
568+
}),
569+
errWithoutMeta,
570+
);
571+
572+
assert.strictEqual(sink.logs.length, 1);
573+
assert.strictEqual(sink.logs[0].fields.severity, 'DEBUG');
574+
assert.deepStrictEqual(sink.logs[0].args, [
575+
'RPC attempt failed: %s',
576+
'client error',
577+
]);
578+
const otherWithoutMeta = sink.logs[0].fields.other as LogPayload;
579+
assert.strictEqual('response.payload' in otherWithoutMeta, false);
580+
assert.strictEqual('exception.type' in otherWithoutMeta, false);
581+
assert.strictEqual('exception.message' in otherWithoutMeta, false);
582+
assert.strictEqual('exception.stacktrace' in otherWithoutMeta, false);
583+
});
584+
585+
it('captures the attempt span context and logs on callback and stream failures', () => {
586+
const stream = new EventEmitter();
587+
let receivedCallback: APICallback | undefined;
588+
589+
traceCall(dynamicArgs, staticArgs, () =>
590+
traceAttempt(dynamicArgs, staticArgs, () =>
591+
logNetworkAttempt(
592+
dynamicArgs,
593+
staticArgs,
594+
loggedCallback => {
595+
receivedCallback = loggedCallback;
596+
return stream;
597+
},
598+
true,
599+
() => {},
600+
),
601+
),
602+
);
603+
604+
const attemptErr = new GoogleError('attempt failed');
605+
receivedCallback!(attemptErr);
606+
stream.emit('error', attemptErr);
607+
608+
const spans = harness.getSpans('google-gax');
609+
assert.strictEqual(spans.length, 2);
610+
const attemptSpan = spans[0];
611+
612+
assert.strictEqual(sink.logs.length, 2);
613+
for (const logEntry of sink.logs) {
614+
assert.strictEqual(logEntry.fields.severity, 'DEBUG');
615+
assert.strictEqual(
616+
logEntry.fields.telemetryTraceId,
617+
attemptSpan.spanContext().traceId,
618+
);
619+
assert.strictEqual(
620+
logEntry.fields.telemetrySpanId,
621+
attemptSpan.spanContext().spanId,
622+
);
623+
assert.deepStrictEqual(logEntry.args, [
624+
'RPC attempt failed: %s',
625+
'attempt failed',
626+
]);
627+
}
628+
});
629+
});
485630
});

0 commit comments

Comments
 (0)