Skip to content

Commit a016d9a

Browse files
committed
feat(telemetry): add span log correlation
1 parent 9a01bf9 commit a016d9a

7 files changed

Lines changed: 225 additions & 18 deletions

File tree

src/api/authInterceptor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,9 @@ export class AuthInterceptor implements vscode.Disposable {
8383
hostname: string,
8484
): Promise<unknown> {
8585
this.logger.debug("Received 401 response, attempting recovery");
86-
// TODO(#925): emit a correlated received-log here once Span.log() lands.
8786
return this.authTelemetry.traceAuthRecovery(async (recorder) => {
87+
recorder.logReceived();
88+
8889
// 1) OAuth refresh path.
8990
const isOAuth =
9091
await this.oauthSessionManager.isLoggedInWithOAuth(hostname);

src/instrumentation/auth.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type LoginPromptOutcome =
1414
| { success: false; reason: LoginPromptReason };
1515

1616
interface AuthRecoveryRecorder {
17+
logReceived(): void;
1718
setRecovery(recovery: AuthRecoveryAction): void;
1819
setRefreshAttempted(attempted: boolean): void;
1920
}
@@ -44,6 +45,7 @@ export class AuthTelemetry {
4445
"auth.unauthorized_intercepted",
4546
(span) =>
4647
fn({
48+
logReceived: () => span.log("received"),
4749
setRecovery: (recovery) => span.setProperty("recovery", recovery),
4850
setRefreshAttempted: (attempted) =>
4951
span.setProperty("refreshAttempted", attempted),

src/telemetry/service.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export class TelemetryService implements vscode.Disposable, TelemetryReporter {
129129
* Run a timed operation. The emitted event carries `durationMs` and a
130130
* `result` of `success`, `error`, or `aborted` (set via `span.markAborted()`
131131
* for intentional early exits). All events from one call share a `traceId`;
132-
* phase children carry `parentEventId`.
132+
* child phases and span logs carry `parentEventId`.
133133
*/
134134
public trace<T>(
135135
eventName: string,
@@ -181,6 +181,21 @@ export class TelemetryService implements vscode.Disposable, TelemetryReporter {
181181
`Telemetry span '${eventName}' ${op}('${name}') called after emit; mutation dropped`,
182182
);
183183
};
184+
const emitSpanLog = (
185+
logName: string,
186+
logProperties: CallerProperties,
187+
logMeasurements: CallerMeasurements,
188+
error?: unknown,
189+
): void => {
190+
const safeName = this.#sanitizeChildName(logName, "log");
191+
this.#safeEmit(
192+
newSpanId(),
193+
`${eventName}.${safeName}`,
194+
stringifyProps(logProperties),
195+
logMeasurements,
196+
{ traceId, parentEventId: eventId, traceLevel, error },
197+
);
198+
};
184199
const span: Span = {
185200
traceId,
186201
eventId,
@@ -189,9 +204,13 @@ export class TelemetryService implements vscode.Disposable, TelemetryReporter {
189204
phaseName: string,
190205
phaseFn: (childSpan: Span) => Promise<U>,
191206
phaseProps: CallerProperties = {},
192-
phaseMeasurements: Record<string, number> = {},
207+
phaseMeasurements: CallerMeasurements = {},
193208
): Promise<U> => {
194-
const safeName = this.#sanitizePhaseName(phaseName);
209+
if (completed) {
210+
warnPostEmit("phase", phaseName);
211+
return phaseFn(NOOP_SPAN);
212+
}
213+
const safeName = this.#sanitizeChildName(phaseName, "phase");
195214
return this.#startSpan(
196215
`${eventName}.${safeName}`,
197216
phaseFn,
@@ -200,6 +219,29 @@ export class TelemetryService implements vscode.Disposable, TelemetryReporter {
200219
{ traceId, parentEventId: eventId, traceLevel },
201220
);
202221
},
222+
log: (
223+
logName: string,
224+
logProperties: CallerProperties = {},
225+
logMeasurements: CallerMeasurements = {},
226+
): void => {
227+
if (completed) {
228+
warnPostEmit("log", logName);
229+
return;
230+
}
231+
emitSpanLog(logName, logProperties, logMeasurements);
232+
},
233+
logError: (
234+
logName: string,
235+
error: unknown,
236+
logProperties: CallerProperties = {},
237+
logMeasurements: CallerMeasurements = {},
238+
): void => {
239+
if (completed) {
240+
warnPostEmit("logError", logName);
241+
return;
242+
}
243+
emitSpanLog(logName, logProperties, logMeasurements, error);
244+
},
203245
setProperty(name: string, value: CallerPropertyValue): void {
204246
if (completed) {
205247
warnPostEmit("setProperty", name);
@@ -243,13 +285,13 @@ export class TelemetryService implements vscode.Disposable, TelemetryReporter {
243285
});
244286
}
245287

246-
#sanitizePhaseName(name: string): string {
288+
#sanitizeChildName(name: string, kind: "phase" | "log"): string {
247289
if (!name.includes(".")) {
248290
return name;
249291
}
250292
const sanitized = name.replaceAll(".", "_");
251293
this.logger.warn(
252-
`Telemetry phase name '${name}' contains '.', sanitized to '${sanitized}'`,
294+
`Telemetry ${kind} name '${name}' contains '.', sanitized to '${sanitized}'`,
253295
);
254296
return sanitized;
255297
}

src/telemetry/span.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import type {
77
export type SpanResult = "success" | "aborted" | "error";
88

99
/**
10-
* Parent span handle. Children's `eventName` composes as `${parent.eventName}.${phaseName}`.
11-
* Phase names should not contain `.`; if they do, dots are replaced with `_` and a warning is logged.
10+
* Parent span handle. Child phases and logs compose as `${parent.eventName}.${name}`.
11+
* Child names should not contain `.`; if they do, dots are replaced with `_` and a warning is logged.
1212
* Recurse via `phase` for grandchildren.
1313
*/
1414
export interface Span {
@@ -22,6 +22,19 @@ export interface Span {
2222
properties?: CallerProperties,
2323
measurements?: CallerMeasurements,
2424
): Promise<T>;
25+
/** Emit a point-in-time log event correlated with this span. */
26+
log(
27+
logName: string,
28+
properties?: CallerProperties,
29+
measurements?: CallerMeasurements,
30+
): void;
31+
/** Emit a point-in-time error log event correlated with this span. */
32+
logError(
33+
logName: string,
34+
error: unknown,
35+
properties?: CallerProperties,
36+
measurements?: CallerMeasurements,
37+
): void;
2538
/** Add or replace a property on the event emitted for this span. */
2639
setProperty(name: string, value: CallerPropertyValue): void;
2740
/** Add or replace a measurement on the event emitted for this span. */
@@ -40,6 +53,8 @@ export const NOOP_SPAN: Span = {
4053
phase<T>(_phaseName: string, fn: (span: Span) => Promise<T>): Promise<T> {
4154
return fn(NOOP_SPAN);
4255
},
56+
log: () => undefined,
57+
logError: () => undefined,
4358
markAborted: () => undefined,
4459
markFailure: () => undefined,
4560
setProperty: () => undefined,

src/telemetry/wireFormat.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const TelemetryEventSchema = z.object({
3232
measurements: z.record(z.string(), z.number()),
3333
/** Shared by all events in a trace. Maps to OTel `trace_id`. */
3434
traceId: z.string().optional(),
35-
/** Parent event in the same trace. Maps to OTel `parent_span_id`. */
35+
/** Parent or correlated span event in the same trace. */
3636
parentEventId: z.string().optional(),
3737
error: z
3838
.object({

test/unit/api/authInterceptor.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,5 +562,29 @@ describe("AuthInterceptor", () => {
562562
sink.expectOne("auth.unauthorized_intercepted").measurements.durationMs,
563563
).toEqual(expect.any(Number));
564564
});
565+
566+
it("emits a received log correlated with the recovery span", async () => {
567+
const sink = new TestSink();
568+
const ctx = createTestContext();
569+
await ctx.setupOAuthTokens();
570+
ctx.mockOAuthManager.refreshToken.mockResolvedValue(
571+
createMockTokenResponse({ access_token: "new" }),
572+
);
573+
vi.spyOn(ctx.axiosInstance, "request").mockResolvedValue({
574+
status: 200,
575+
});
576+
ctx.createInterceptor(undefined, createTestTelemetryService(sink));
577+
578+
await ctx.axiosInstance.triggerResponseError(
579+
createAxiosError(401, "Unauthorized"),
580+
);
581+
582+
const received = sink.expectOne("auth.unauthorized_intercepted.received");
583+
const recovery = sink.expectOne("auth.unauthorized_intercepted");
584+
expect(received.traceId).toBe(recovery.traceId);
585+
expect(received.parentEventId).toBe(recovery.eventId);
586+
expect(received.measurements.durationMs).toBeUndefined();
587+
expect(received.properties.result).toBeUndefined();
588+
});
565589
});
566590
});

test/unit/telemetry/service.test.ts

Lines changed: 132 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ describe("TelemetryService", () => {
9191
});
9292
});
9393

94+
it("leaves top-level logs uncorrelated", () => {
95+
h.service.log("plain");
96+
h.service.logError("plain.error", new Error("nope"));
97+
98+
const [log, logError] = h.sink.events;
99+
expect(log.traceId).toBeUndefined();
100+
expect(log.parentEventId).toBeUndefined();
101+
expect(logError.traceId).toBeUndefined();
102+
expect(logError.parentEventId).toBeUndefined();
103+
});
104+
94105
describe("trace", () => {
95106
it("returns the wrapped value and records durationMs on success", async () => {
96107
vi.useFakeTimers();
@@ -165,6 +176,78 @@ describe("TelemetryService", () => {
165176
});
166177
});
167178

179+
it("span.log emits a correlated point-in-time event", async () => {
180+
await h.service.trace("op", (span) => {
181+
span.log("checkpoint", { ready: true }, { count: 1 });
182+
return Promise.resolve();
183+
});
184+
185+
const [log, parent] = h.sink.events;
186+
expect(log).toMatchObject({
187+
eventName: "op.checkpoint",
188+
properties: { ready: "true" },
189+
measurements: { count: 1 },
190+
});
191+
expect(log.eventId).not.toBe(parent.eventId);
192+
expect(log.traceId).toBe(parent.traceId);
193+
expect(log.parentEventId).toBe(parent.eventId);
194+
expect(log.properties.result).toBeUndefined();
195+
expect(log.measurements.durationMs).toBeUndefined();
196+
});
197+
198+
it("span.logError emits a correlated error log", async () => {
199+
await h.service.trace("op", (span) => {
200+
span.logError(
201+
"failed",
202+
new TypeError("nope"),
203+
{ attempt: 1 },
204+
{ retries: 2 },
205+
);
206+
return Promise.resolve();
207+
});
208+
209+
const [log, parent] = h.sink.events;
210+
expect(log).toMatchObject({
211+
eventName: "op.failed",
212+
properties: { attempt: "1" },
213+
measurements: { retries: 2 },
214+
error: { message: "nope", type: "TypeError" },
215+
});
216+
expect(log.traceId).toBe(parent.traceId);
217+
expect(log.parentEventId).toBe(parent.eventId);
218+
expect(log.measurements.durationMs).toBeUndefined();
219+
});
220+
221+
it("child span logs point at the child span", async () => {
222+
await h.service.trace("op", async (span) => {
223+
await span.phase("phase", (childSpan) => {
224+
childSpan.log("checkpoint");
225+
childSpan.logError("oops", new Error("boom"));
226+
return Promise.resolve();
227+
});
228+
});
229+
230+
const [log, logError, child, parent] = h.sink.events;
231+
expect(log.eventName).toBe("op.phase.checkpoint");
232+
expect(log.traceId).toBe(parent.traceId);
233+
expect(log.parentEventId).toBe(child.eventId);
234+
expect(logError.eventName).toBe("op.phase.oops");
235+
expect(logError.traceId).toBe(parent.traceId);
236+
expect(logError.parentEventId).toBe(child.eventId);
237+
expect(logError.error).toMatchObject({ message: "boom" });
238+
expect(child.parentEventId).toBe(parent.eventId);
239+
});
240+
241+
it("span log names containing '.' are sanitized like phase names", async () => {
242+
await h.service.trace("op", (span) => {
243+
span.log("bad.name");
244+
return Promise.resolve();
245+
});
246+
247+
const [log] = h.sink.events;
248+
expect(log.eventName).toBe("op.bad_name");
249+
});
250+
168251
it("flat traces (no phases) emit a single event with a fresh traceId", async () => {
169252
await h.service.trace("a", () => Promise.resolve(1));
170253
await h.service.trace("b", () => Promise.resolve(2));
@@ -197,15 +280,15 @@ describe("TelemetryService", () => {
197280
expect(phase2.traceId).toBe(parent.traceId);
198281
});
199282

200-
it("phase children carry parentEventId pointing at the parent's eventId; logs do not", async () => {
283+
it("phase children carry parentEventId pointing at the parent's eventId; top-level logs do not", async () => {
201284
h.service.log("plain");
202285
await h.service.trace("op", async (span) => {
203286
await span.phase("p1", () => Promise.resolve());
204287
await span.phase("p2", () => Promise.resolve());
205288
});
206289

207290
const [plain, p1, p2, parent] = h.sink.events;
208-
// log/logError/time events have no parentEventId field.
291+
// Top-level log/logError events have no parentEventId field.
209292
expect(plain.parentEventId).toBeUndefined();
210293
// Parent (root) has no parentEventId either.
211294
expect(parent.parentEventId).toBeUndefined();
@@ -323,6 +406,35 @@ describe("TelemetryService", () => {
323406
);
324407
});
325408

409+
it("drops span logs called after emit", async () => {
410+
let escapedSpan: Span | undefined;
411+
await h.service.trace("op", (span) => {
412+
escapedSpan = span;
413+
return Promise.resolve();
414+
});
415+
416+
escapedSpan?.log("late");
417+
escapedSpan?.logError("late_error", new Error("ignored"));
418+
419+
expect(h.sink.events).toHaveLength(1);
420+
});
421+
422+
it("runs phase fns called after emit but emits no phase event", async () => {
423+
let escapedSpan: Span | undefined;
424+
await h.service.trace("op", (span) => {
425+
escapedSpan = span;
426+
return Promise.resolve();
427+
});
428+
429+
const result = await escapedSpan?.phase("late", (childSpan) => {
430+
childSpan.log("ignored");
431+
return Promise.resolve("ran");
432+
});
433+
434+
expect(result).toBe("ran");
435+
expect(h.sink.events).toHaveLength(1);
436+
});
437+
326438
it("markAborted flips result to 'aborted' on normal return", async () => {
327439
await h.service.trace("op", (span) => {
328440
span.markAborted();
@@ -456,12 +568,20 @@ describe("TelemetryService", () => {
456568
h.service.log("a");
457569
h.service.logError("b", new Error("ignored"));
458570

459-
expect(await h.service.trace("c", () => Promise.resolve(42))).toBe(42);
571+
expect(
572+
await h.service.trace("c", (span) => {
573+
span.log("ignored");
574+
span.logError("ignored_error", new Error("ignored"));
575+
return Promise.resolve(42);
576+
}),
577+
).toBe(42);
460578

461579
const traceResult = await h.service.trace("d", async (span) => {
462-
const phaseValue = await span.phase("p", () =>
463-
Promise.resolve("inner"),
464-
);
580+
const phaseValue = await span.phase("p", (childSpan) => {
581+
childSpan.log("ignored");
582+
childSpan.logError("ignored_error", new Error("ignored"));
583+
return Promise.resolve("inner");
584+
});
465585
expect(phaseValue).toBe("inner");
466586
return "outer";
467587
});
@@ -571,9 +691,12 @@ describe("TelemetryService", () => {
571691
it("trace and span.phase complete normally when the sink throws", async () => {
572692
const service = makeService([throwingSink]);
573693
const result = await service.trace("op", async (span) => {
574-
const phaseValue = await span.phase("p", () =>
575-
Promise.resolve("phase"),
576-
);
694+
span.log("checkpoint");
695+
span.logError("failure", new Error("telemetry-error"));
696+
const phaseValue = await span.phase("p", (childSpan) => {
697+
childSpan.log("checkpoint");
698+
return Promise.resolve("phase");
699+
});
577700
expect(phaseValue).toBe("phase");
578701
return "trace";
579702
});

0 commit comments

Comments
 (0)