otlpLogEmitter.ts ×29

Frontier kind: Code frontier

unlabeled · c_90bc36504478

497 tests · 8069 LOC · 38 files · introduces 0 tests · 239 LOC · 1 file

Introduces — evidence that enters the hierarchy at this concept

Code
29 ranges239 lines · 1 files
Tests
0 tests

Contains — complete concept membership

All code (extent)
1270 ranges8069 lines · 38 files · Browse complete extent
All tests (intent)
497 testsBrowse complete intent

Neighbourhood graph

The orange circle is the focus. Violet and green circles are every ancestor and descendant, broader and narrower, at any distance; blue squares and pink diamonds are the introduced files and exact introduced tests of every visible concept, not only the focus's. Arrows point from broader to narrower concepts and bridge only concepts omitted from this view. Undirected links show source or test introduction. Concept and file size follows LOC; exact test nodes use test-count units.

Introduced files, introduced tests, and structurally relevant concept specialization

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 native relationship evidence on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the native relationship evidence on this page while the interactive map is unavailable.

Native relationship evidence

Every exact file and test below is linked only from the concept that introduces it.

Introduced tests

Every collected test enters the hierarchy at exactly one concept.

No tests are introduced at this concept. Its intent tests are introduced by other concepts.

Introduced code

Every collected source range enters the hierarchy at exactly one concept.

1 file ranked by introduced lines: 239 introduced LOC across 29 ranges. Expand a file to inspect source; the > gutter marks introduced lines.

src/vs/platform/agentHost/common/otlp/otlpLogEmitter.ts 239 introduced LOC · 29 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- otlpLogEmitter.ts
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) {
42 case 'trace': return 1;
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();
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) {
67 case LogLevel.Trace: return { severityNumber: 1, severityText: 'trace' };
76 }
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) {
87 return undefined;
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; }
100 if (severityNumber >= 13) { return LogLevel.Warning; }
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 {
171
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);
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,
191 initialLevel: LogLevel = LogLevel.Trace,
198 this.setLevel(initialLevel);
199 }
201 > override trace(message: string, ...args: unknown[]): void {
202 if (this.canLog(LogLevel.Trace)) {
203 this._emit(LogLevel.Trace, message, args, true);
204 }
205 }
207 > override debug(message: string, ...args: unknown[]): void {
208 if (this.canLog(LogLevel.Debug)) {
209 this._emit(LogLevel.Debug, message, args);
210 }
211 }
213 > override info(message: string, ...args: unknown[]): void {
214 if (this.canLog(LogLevel.Info)) {
215 this._emit(LogLevel.Info, message, args);
216 }
217 }
219 > override warn(message: string, ...args: unknown[]): void {
220 if (this.canLog(LogLevel.Warning)) {
221 this._emit(LogLevel.Warning, message, args);
222 }
223 }
225 > override error(message: string | Error, ...args: unknown[]): void {
226 if (this.canLog(LogLevel.Error)) {
227 const head = message instanceof Error ? message.stack ?? message.message : message;
229 }
230 }
232 > protected override log(level: LogLevel, message: string): void {
233 if (level === LogLevel.Off) {
234 return;
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;
248 const index = args.findIndex(arg => arg instanceof OtelData);
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]);
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 {
284 resourceLogs: [
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') {
317 return;
346 }
347 }
349 function coerceLogRecord(raw: unknown): IOtlpLogRecord | undefined {
350 if (!raw || typeof raw !== 'object') {
364 : { timeUnixNano, severityNumber, severityText, body };
365 }
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> }> {
372 return Object.entries(attributes).map(([key, value]) => ({ key, value: toAnyValue(value) }));
373 }
375 function toAnyValue(value: OtelAttributeValue): Record<string, unknown> {
376 switch (typeof 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 {
389 if (!Array.isArray(raw)) {
406 return Object.keys(result).length > 0 ? result : undefined;
407 }
409 function fromAnyValue(value: unknown): OtelAttributeValue | undefined {
410 if (!value || typeof value !== 'object') {
422 return undefined;
423 }
425 function severityNameFromNumber(n: number): OtlpLogLevelName {
426 if (n >= 21) { return 'fatal'; }
431 return 'trace';
432 }
434 function extractBody(body: unknown): string {
435 if (typeof body === 'string') {
444 return '';
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 {
453 // Avoid `BigInt` so this works in renderers and worker environments
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
468 // segment. We avoid `URI.parse` here so this helper can run in
474 return parseOtlpLogLevel(match[1]);
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}`;
485 }