src/vs/platform/agentHost/common/otlp/otlpLogEmitter.ts

485 LOC · 457 covered · 28 uncovered · 93 ranges · 1065 concepts · 24 introducers · 497 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- otlpLogEmitter.ts ×29
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { Emitter, Event } from '../../../../base/common/event.js';
7 > import { Disposable } from '../../../../base/common/lifecycle.js';
8 > import { AbstractMessageLogger, format, LogLevel } from '../../../log/common/log.js';
9 >
10 > /**
11 > * Channel URI template advertised by an agent host that has an
12 > * {@link OtlpLogEmitter} attached. Clients expand `{level}` to one of the
13 > * short OTLP severity names (`trace`/`debug`/`info`/`warn`/`error`/`fatal`)
14 > * and subscribe to the resulting concrete URI.
15 > *
16 > * Kept as a constant so producer (host) and consumer (workbench) cannot
17 > * drift out of sync.
18 > */
19 > export const OTLP_LOGS_CHANNEL_TEMPLATE = 'ahp-otlp://logs/{level}';
20 >
21 > /**
22 > * Scheme used by every OTLP channel URI. Lets routers tell them apart from
23 > * `ahp-*` state channels by URI alone.
24 > */
25 > export const OTLP_CHANNEL_SCHEME = 'ahp-otlp';
26 >
27 > /**
28 > * Short OTLP severity names defined by the protocol's `{level}` template
29 > * variable. Listed in ascending order so a numeric index can act as a
30 > * coarse "minimum severity" bucket.
31 > */
32 > export const OTLP_LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const;
33 > export type OtlpLogLevelName = typeof OTLP_LOG_LEVELS[number];
34 >
35 > /**
36 > * Lowest [OTLP `SeverityNumber`](https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber)
37 > * within each named severity band. A record is delivered when its
38 > * `severityNumber >= levelToSeverityNumber(subscribed level)`.
39 > */
40 > export function levelToSeverityNumber(level: OtlpLogLevelName): number {
41 > switch (level) { otlpLogEmitter.ts ×1
42 > case 'trace': return 1;
43 > case 'debug': return 5;
44 > case 'info': return 9;
45 > case 'warn': return 13;
46 > case 'error': return 17;
47 > case 'fatal': return 21;
48 > }
49 > }
51 > /**
52 > * Parse one of {@link OTLP_LOG_LEVELS} from an arbitrary (case-insensitive)
53 > * string. Returns `undefined` for anything that is not a recognised name so
54 > * callers can decide how to handle unknown levels.
55 > */
56 > export function parseOtlpLogLevel(value: string): OtlpLogLevelName | undefined {
57 > const lower = value.toLowerCase(); otlpLogEmitter.ts ×1
58 > return (OTLP_LOG_LEVELS as readonly string[]).includes(lower) ? lower as OtlpLogLevelName : undefined;
59 > }
61 > /**
62 > * Map a VS Code {@link LogLevel} onto the corresponding OTLP
63 > * `SeverityNumber` and short name.
64 > */
65 > export function logLevelToOtlpSeverity(level: LogLevel): { severityNumber: number; severityText: OtlpLogLevelName } {
66 > switch (level) { otlpLogEmitter.ts ×2
67 > case LogLevel.Trace: return { severityNumber: 1, severityText: 'trace' };
68 > case LogLevel.Debug: return { severityNumber: 5, severityText: 'debug' };
69 > case LogLevel.Info: return { severityNumber: 9, severityText: 'info' };
70 > case LogLevel.Warning: return { severityNumber: 13, severityText: 'warn' };
71 > case LogLevel.Error: return { severityNumber: 17, severityText: 'error' };
72 > case LogLevel.Off:
73 // `Off` is filtered out before we ever reach this function — but
74 // pick a sentinel so callers can defend if they get here anyway.
75 return { severityNumber: 0, severityText: 'trace' };
77 > }
79 > /**
80 > * Map a VS Code {@link LogLevel} onto the matching OTLP level name used in
81 > * the protocol's `{level}` template. Returns `undefined` for
82 > * {@link LogLevel.Off} since "no logs" is represented by not subscribing
83 > * at all.
84 > */
85 > export function logLevelToOtlpLevelName(level: LogLevel): OtlpLogLevelName | undefined {
86 > if (level === LogLevel.Off) { otlpLogEmitter.ts ×1
87 > return undefined;
88 > }
89 > return logLevelToOtlpSeverity(level).severityText;
90 > }
92 > /**
93 > * Reverse of {@link logLevelToOtlpSeverity}: returns the closest VS Code
94 > * log level for a given OTLP `SeverityNumber`. Used on the client side to
95 > * pick which `ILogger.{trace,debug,...}` call to route an incoming record
96 > * through.
97 > */
98 > export function severityNumberToLogLevel(severityNumber: number): LogLevel {
99 > if (severityNumber >= 17) { return LogLevel.Error; } otlpLogEmitter.ts ×1
100 > if (severityNumber >= 13) { return LogLevel.Warning; }
101 > if (severityNumber >= 9) { return LogLevel.Info; }
102 > if (severityNumber >= 5) { return LogLevel.Debug; }
103 > return LogLevel.Trace;
104 > }
106 > /**
107 > * Scalar value types accepted for a structured log attribute. Mirrors the
108 > * subset of OTLP `AnyValue` we serialise on the wire
109 > * (`stringValue`/`intValue`/`doubleValue`/`boolValue`).
110 > */
111 > export type OtelAttributeValue = string | number | boolean;
112 >
113 > /**
114 > * Structured metadata a log call can carry alongside its human-readable
115 > * message. Pass an instance as the final argument to any `ILogger` method
116 > * (e.g. `logService.info('MCP server started', new OtelData({ server: 'github' }))`).
117 > *
118 > * - For the regular file logger the value is rendered via {@link toJSON}
119 > * using the usual log formatting (`JSON.stringify`), so it appears as a
120 > * compact object after the message.
121 > * - For the {@link OtlpEmitterLogger} the attributes are lifted out of the
122 > * message and emitted as spec-conformant OTLP `LogRecord.attributes`,
123 > * keeping the body free of serialised JSON.
124 > */
125 > export class OtelData {
126 > constructor(readonly attributes: Readonly<Record<string, OtelAttributeValue>>) { }
127 >
128 > toJSON(): Readonly<Record<string, OtelAttributeValue>> {
129 return this.attributes;
130 }
132 >
133 > /**
134 > * A single log record produced by an {@link OtlpEmitterLogger}. The shape
135 > * mirrors the relevant fields from the OTLP/JSON `LogRecord` spec but is
136 > * kept intentionally small — only what we need to populate a
137 > * spec-conformant `ExportLogsServiceRequest` envelope.
138 > */
139 > export interface IOtlpLogRecord {
140 > /**
141 > * Time the record was produced, in nanoseconds since the Unix epoch.
142 > * Encoded as a string because JS numbers cannot losslessly represent
143 > * 64-bit nanosecond timestamps (this matches the OTLP/JSON wire format).
144 > */
145 > readonly timeUnixNano: string;
146 > /** OTLP `SeverityNumber` in the range 1..24 (see {@link levelToSeverityNumber}). */
147 > readonly severityNumber: number;
148 > /** Short severity name (`trace`/`debug`/...) matching the protocol's `{level}` vocabulary. */
149 > readonly severityText: OtlpLogLevelName;
150 > /**
151 > * Pre-formatted log body. We send the same string the existing
152 > * `ILogger` printed to the file logger — the OTLP spec models this as
153 > * `body: { stringValue }`.
154 > */
155 > readonly body: string;
156 > /**
157 > * Optional structured metadata carried by an {@link OtelData} argument
158 > * on the originating log call. Serialised to OTLP `LogRecord.attributes`
159 > * and absent when the call had no {@link OtelData}.
160 > */
161 > readonly attributes?: Readonly<Record<string, OtelAttributeValue>>;
162 > }
163 >
164 > /**
165 > * Connection-process-wide hub that {@link OtlpEmitterLogger} writes to and
166 > * the protocol server reads from. Decouples log production (which happens
167 > * via {@link ILogger}) from protocol broadcast (which needs awareness of
168 > * connected clients and their subscribed severity).
169 > */
170 > export class OtlpLogEmitter extends Disposable {
172 > private readonly _onDidLog = this._register(new Emitter<IOtlpLogRecord>());
173 > readonly onDidLog: Event<IOtlpLogRecord> = this._onDidLog.event;
175 > emit(record: IOtlpLogRecord): void {
176 > this._onDidLog.fire(record); otlpLogEmitter.ts ×1
177 > }
179 >
180 > /**
181 > * `AbstractMessageLogger` that converts each `log(level, message)` call
182 > * into an {@link IOtlpLogRecord} and emits it on the shared
183 > * {@link OtlpLogEmitter}. Designed to be installed alongside the regular
184 > * file logger via `new LogService(primary, [otlpLogger])` so every log
185 > * call is mirrored to OTLP subscribers without duplicating call sites.
186 > */
187 > export class OtlpEmitterLogger extends AbstractMessageLogger {
188 >
189 > constructor(
190 > private readonly _emitter: OtlpLogEmitter, otlpLogEmitter.ts ×7
191 > initialLevel: LogLevel = LogLevel.Trace,
192 > ) {
193 > super();
194 > // Default to `Trace` so the parent `LogService`'s level check is
195 > // the single source of truth and we don't accidentally drop
196 > // records the file logger printed. The protocol's `{level}`
197 > // filter is applied per-subscriber later on.
198 > this.setLevel(initialLevel);
199 > }
201 > override trace(message: string, ...args: unknown[]): void {
202 > if (this.canLog(LogLevel.Trace)) { otlpLogEmitter.ts ×5
203 > this._emit(LogLevel.Trace, message, args, true); otlpLogEmitter.ts ×2
204 > }
207 > override debug(message: string, ...args: unknown[]): void {
208 > if (this.canLog(LogLevel.Debug)) { otlpLogEmitter.ts ×5
209 > this._emit(LogLevel.Debug, message, args); otlpLogEmitter.ts ×2
210 > }
213 > override info(message: string, ...args: unknown[]): void {
214 > if (this.canLog(LogLevel.Info)) { otlpLogEmitter.ts ×7
215 > this._emit(LogLevel.Info, message, args); otlpLogEmitter.ts ×1
216 > }
219 > override warn(message: string, ...args: unknown[]): void {
220 > if (this.canLog(LogLevel.Warning)) { otlpLogEmitter.ts ×7
221 > this._emit(LogLevel.Warning, message, args);
222 > }
223 > }
225 > override error(message: string | Error, ...args: unknown[]): void {
226 > if (this.canLog(LogLevel.Error)) { otlpLogEmitter.ts ×5
227 > const head = message instanceof Error ? message.stack ?? message.message : message;
228 > this._emit(LogLevel.Error, head, args);
229 > }
230 > }
232 > protected override log(level: LogLevel, message: string): void {
233 if (level === LogLevel.Off) {
234 return;
235 }
236 this._emit(level, message, []);
237 }
239 > /**
240 > * Formats `message` + `args` into the OTLP record body, lifting any
241 > * {@link OtelData} argument out into structured `attributes` so the
242 > * metadata is emitted over the channel rather than serialised into the
243 > * body. Mirrors the formatting the base `AbstractMessageLogger` would
244 > * apply (including the verbose flag for `trace`).
245 > */
246 > private _emit(level: LogLevel, message: string, args: unknown[], verbose = false): void {
247 > let attributes: Readonly<Record<string, OtelAttributeValue>> | undefined; otlpLogEmitter.ts ×7
248 > const index = args.findIndex(arg => arg instanceof OtelData);
249 > if (index !== -1) {
250 > attributes = (args[index] as OtelData).attributes; otlpLogEmitter.ts ×1
251 > args = args.slice(0, index).concat(args.slice(index + 1));
252 > }
253 > const { severityNumber, severityText } = logLevelToOtlpSeverity(level); otlpLogEmitter.ts ×7
254 > this._emitter.emit({
255 > timeUnixNano: msToUnixNano(Date.now()),
256 > severityNumber,
257 > severityText,
258 > body: format([message, ...args], verbose),
259 > ...(attributes ? { attributes } : undefined),
260 > });
261 > }
263 >
264 > /**
265 > * Build an OTLP/JSON `ExportLogsServiceRequest` envelope from a single
266 > * record. The payload is the minimum the spec allows: one `ResourceLogs` →
267 > * one `ScopeLogs` → one `LogRecord`.
268 > *
269 > * Callers that want to batch multiple records can use
270 > * {@link toResourceLogsPayloadBatch}.
271 > */
272 > export function toResourceLogsPayload(record: IOtlpLogRecord): Record<string, unknown> {
273 > return toResourceLogsPayloadBatch([record]); otlpLogEmitter.ts ×2
274 > }
276 > /**
277 > * Build an OTLP/JSON `ExportLogsServiceRequest` envelope from a batch of
278 > * records. All records share the same `ResourceLogs`/`ScopeLogs` parent —
279 > * which is fine since this emitter only ever runs inside a single agent
280 > * host process and instrumentation scope.
281 > */
282 > export function toResourceLogsPayloadBatch(records: readonly IOtlpLogRecord[]): Record<string, unknown> {
283 > return { otlpLogEmitter.ts ×2
284 > resourceLogs: [
285 > {
286 > resource: { attributes: [] },
287 > scopeLogs: [
288 > {
289 > scope: { name: 'vscode.agentHost' },
290 > logRecords: records.map(r => ({
291 > timeUnixNano: r.timeUnixNano,
292 > observedTimeUnixNano: r.timeUnixNano,
293 > severityNumber: r.severityNumber,
294 > severityText: r.severityText,
295 > body: { stringValue: r.body },
296 > ...(r.attributes ? { attributes: attributesToOtlp(r.attributes) } : undefined),
297 > })),
298 > },
299 > ],
300 > },
301 > ],
302 > };
303 > }
305 > /**
306 > * Walk an OTLP/JSON `ExportLogsServiceRequest` payload and yield each
307 > * embedded `LogRecord` in a shape matching {@link IOtlpLogRecord}. Used by
308 > * clients to decode incoming `otlp/exportLogs` notifications without
309 > * dragging in an OpenTelemetry SDK.
310 > *
311 > * Anything that does not look like a log record is silently skipped —
312 > * the OTLP spec gives hosts considerable freedom and we don't want a
313 > * malformed nested object to bring down the entire batch.
314 > */
315 > export function* iterateOtlpLogRecords(payload: unknown): IterableIterator<IOtlpLogRecord> {
316 > if (!payload || typeof payload !== 'object') { otlpLogEmitter.ts ×15
317 > return; otlpLogEmitter.ts ×6
318 > }
319 > const resourceLogs = (payload as { resourceLogs?: unknown }).resourceLogs; otlpLogEmitter.ts ×15
320 > if (!Array.isArray(resourceLogs)) {
321 > return; otlpLogEmitter.ts ×6
322 > }
323 > for (const resourceLog of resourceLogs) { otlpLogEmitter.ts ×15
324 > if (!resourceLog || typeof resourceLog !== 'object') {
325 continue;
326 }
327 > const scopeLogs = (resourceLog as { scopeLogs?: unknown }).scopeLogs; otlpLogEmitter.ts ×15
328 > if (!Array.isArray(scopeLogs)) {
329 continue;
330 }
331 > for (const scopeLog of scopeLogs) { otlpLogEmitter.ts ×15
332 > if (!scopeLog || typeof scopeLog !== 'object') {
333 continue;
334 }
335 > const logRecords = (scopeLog as { logRecords?: unknown }).logRecords; otlpLogEmitter.ts ×15
336 > if (!Array.isArray(logRecords)) {
337 continue;
338 }
339 > for (const raw of logRecords) { otlpLogEmitter.ts ×15
340 > const record = coerceLogRecord(raw);
341 > if (record) {
342 > yield record;
343 > }
344 > }
345 > }
346 > }
347 > }
349 > function coerceLogRecord(raw: unknown): IOtlpLogRecord | undefined { otlpLogEmitter.ts ×15
350 > if (!raw || typeof raw !== 'object') {
351 > return undefined; otlpLogEmitter.ts ×6
352 > }
353 > const r = raw as Record<string, unknown>; otlpLogEmitter.ts ×15
354 > const severityNumber = typeof r.severityNumber === 'number' ? r.severityNumber : 0;
355 > const severityTextRaw = typeof r.severityText === 'string' ? r.severityText.toLowerCase() : '';
356 > const severityText = parseOtlpLogLevel(severityTextRaw) ?? severityNameFromNumber(severityNumber);
357 > const timeUnixNano = typeof r.timeUnixNano === 'string'
358 > ? r.timeUnixNano otlpLogEmitter.ts ×2
359 > : typeof r.observedTimeUnixNano === 'string' ? r.observedTimeUnixNano : '0'; otlpLogEmitter.ts ×6
360 > const body = extractBody(r.body); otlpLogEmitter.ts ×15
361 > const attributes = otlpToAttributes(r.attributes);
362 > return attributes
363 > ? { timeUnixNano, severityNumber, severityText, body, attributes } otlpLogEmitter.ts ×6
364 > : { timeUnixNano, severityNumber, severityText, body }; otlpLogEmitter.ts ×2
367 > /**
368 > * Serialise a flat attribute map into the OTLP `KeyValue[]` shape, mapping
369 > * each JS scalar onto the matching `AnyValue` variant.
370 > */
371 > function attributesToOtlp(attributes: Readonly<Record<string, OtelAttributeValue>>): Array<{ key: string; value: Record<string, unknown> }> { otlpLogEmitter.ts ×2
372 > return Object.entries(attributes).map(([key, value]) => ({ key, value: toAnyValue(value) }));
373 > }
375 > function toAnyValue(value: OtelAttributeValue): Record<string, unknown> { otlpLogEmitter.ts ×2
376 > switch (typeof value) {
377 > case 'boolean': return { boolValue: value };
378 > case 'number': return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
379 > default: return { stringValue: value };
380 > }
381 > }
383 > /**
384 > * Reverse of {@link attributesToOtlp}: decode an OTLP `KeyValue[]` back into
385 > * a flat attribute map. Returns `undefined` when there are no usable
386 > * attributes so callers can omit the field entirely.
387 > */
388 > function otlpToAttributes(raw: unknown): Record<string, OtelAttributeValue> | undefined { otlpLogEmitter.ts ×15
389 > if (!Array.isArray(raw)) {
390 > return undefined; otlpLogEmitter.ts ×2
391 > }
392 > const result: Record<string, OtelAttributeValue> = {}; otlpLogEmitter.ts ×6
393 > for (const entry of raw) {
394 > if (!entry || typeof entry !== 'object') {
395 continue;
396 }
397 > const key = (entry as { key?: unknown }).key; otlpLogEmitter.ts ×6
398 > if (typeof key !== 'string') {
399 continue;
400 }
401 > const value = fromAnyValue((entry as { value?: unknown }).value); otlpLogEmitter.ts ×6
402 > if (value !== undefined) {
403 > result[key] = value;
404 > }
405 > }
406 > return Object.keys(result).length > 0 ? result : undefined; otlpLogEmitter.ts ×15
407 > }
409 > function fromAnyValue(value: unknown): OtelAttributeValue | undefined { otlpLogEmitter.ts ×6
410 > if (!value || typeof value !== 'object') {
411 return undefined;
412 }
413 > const v = value as Record<string, unknown>; otlpLogEmitter.ts ×6
414 > if (typeof v.stringValue === 'string') { return v.stringValue; }
415 > if (typeof v.boolValue === 'boolean') { return v.boolValue; }
416 > if (typeof v.intValue === 'number') { return Number.isSafeInteger(v.intValue) ? v.intValue : undefined; }
417 > if (typeof v.intValue === 'string') {
418 > const parsed = Number(v.intValue);
419 > return Number.isSafeInteger(parsed) ? parsed : undefined;
420 > }
421 > if (typeof v.doubleValue === 'number') { return Number.isFinite(v.doubleValue) ? v.doubleValue : undefined; } otlpLogEmitter.ts ×1
422 return undefined;
423 }
425 > function severityNameFromNumber(n: number): OtlpLogLevelName { otlpLogEmitter.ts ×6
426 > if (n >= 21) { return 'fatal'; }
427 > if (n >= 17) { return 'error'; }
428 > if (n >= 13) { return 'warn'; }
429 > if (n >= 9) { return 'info'; }
430 > if (n >= 5) { return 'debug'; }
431 > return 'trace';
432 > }
434 > function extractBody(body: unknown): string { otlpLogEmitter.ts ×15
435 > if (typeof body === 'string') {
436 return body;
437 }
438 > if (body && typeof body === 'object') { otlpLogEmitter.ts ×15
439 > const value = (body as { stringValue?: unknown }).stringValue; otlpLogEmitter.ts ×2
440 > if (typeof value === 'string') {
441 > return value;
442 > }
443 > }
444 > return ''; otlpLogEmitter.ts ×6
445 > }
447 > /**
448 > * Convert a millisecond Unix timestamp to a string-encoded nanosecond
449 > * Unix timestamp (the OTLP/JSON wire format). We don't have sub-millisecond
450 > * precision on the producer side, so we just pad with `'000000'`.
451 > */
452 > function msToUnixNano(ms: number): string { otlpLogEmitter.ts ×7
453 > // Avoid `BigInt` so this works in renderers and worker environments
454 > // that block `bigint` in JSON serialization paths.
455 > return `${ms}000000`;
456 > }
458 > /**
459 > * Parse an `ahp-otlp:` channel URI string and extract the `{level}` path
460 > * segment (if present). Returns the parsed level — or `undefined` if the
461 > * URI does not encode one or the encoded value is not a recognised name.
462 > *
463 > * The URI shape advertised by this host implementation is
464 > * `ahp-otlp://logs/<level>` where `<level>` is one of {@link OTLP_LOG_LEVELS}.
465 > */
466 > export function extractLevelFromOtlpLogsUri(uri: string): OtlpLogLevelName | undefined {
467 > // Strip the scheme + authority prefix; the level is the last path otlpLogEmitter.ts ×2
468 > // segment. We avoid `URI.parse` here so this helper can run in
469 > // environments that haven't pulled in the URI module (e.g. tests).
470 > const match = /^ahp-otlp:\/\/logs\/([^/?#]+)/i.exec(uri);
471 > if (!match) {
472 > return undefined; otlpLogEmitter.ts ×1
473 > }
474 > return parseOtlpLogLevel(match[1]); otlpLogEmitter.ts ×2
475 > }
477 > /**
478 > * Build the concrete `ahp-otlp:` channel URI a client subscribes to for a
479 > * given minimum severity. Used both by clients deciding which URI to send
480 > * with `subscribe` and by hosts deciding which URI to put on outbound
481 > * notifications.
482 > */
483 > export function buildOtlpLogsChannelUri(level: OtlpLogLevelName): string {
484 > return `ahp-otlp://logs/${level}`; otlpLogEmitter.ts ×1
485 > }