Skip to content

Commit 06327d0

Browse files
authored
feat(telemetry): Phase A core service infrastructure (#919)
Lays the producer-side foundation for extension telemetry. The service fans out structured events to a list of sinks; this PR ships the service, event model, and trace abstraction only. No sinks, no callers, and no user-facing setting. Phase B will add the first sink and start instrumenting. - TelemetryService owns the event pipeline: session context, level gating, sink fan-out, and lifecycle (flush and dispose) - Event model is shaped after OpenTelemetry resource and trace attributes so a future exporter is a thin adapter - Span and phase form a small tracing API where every event in a trace shares one traceId and child phases carry parentEventId - Reactive level: the service watches coder.telemetry.level and flushes when telemetry is turned off - Caller properties and measurements reserve the framework-managed keys (result, durationMs) at the type level - Setting is intentionally unregistered until the first sink ships, so nothing user-visible activates from this PR alone Closes #900
1 parent fad6a40 commit 06327d0

13 files changed

Lines changed: 1096 additions & 24 deletions

File tree

src/configWatcher.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,18 @@ export interface WatchedSetting {
88

99
/**
1010
* Watch for configuration changes and invoke a callback when values change.
11+
* The callback receives a map of changed settings to their new values, so
12+
* consumers can act on the new value without re-reading the configuration.
1113
* Only fires when actual values change, not just when settings are touched.
1214
*/
1315
export function watchConfigurationChanges(
1416
settings: WatchedSetting[],
15-
onChange: (changedSettings: string[]) => void,
17+
onChange: (changes: ReadonlyMap<string, unknown>) => void,
1618
): vscode.Disposable {
1719
const appliedValues = new Map(settings.map((s) => [s.setting, s.getValue()]));
1820

1921
return vscode.workspace.onDidChangeConfiguration((e) => {
20-
const changedSettings: string[] = [];
22+
const changes = new Map<string, unknown>();
2123

2224
for (const { setting, getValue } of settings) {
2325
if (!e.affectsConfiguration(setting)) {
@@ -27,13 +29,13 @@ export function watchConfigurationChanges(
2729
const newValue = getValue();
2830

2931
if (!configValuesEqual(newValue, appliedValues.get(setting))) {
30-
changedSettings.push(setting);
32+
changes.set(setting, newValue);
3133
appliedValues.set(setting, newValue);
3234
}
3335
}
3436

35-
if (changedSettings.length > 0) {
36-
onChange(changedSettings);
37+
if (changes.size > 0) {
38+
onChange(changes);
3739
}
3840
});
3941
}

src/core/container.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as vscode from "vscode";
33
import { CoderApi } from "../api/coderApi";
44
import { LoginCoordinator } from "../login/loginCoordinator";
55
import { OAuthCallback } from "../oauth/oauthCallback";
6+
import { TelemetryService } from "../telemetry/service";
67
import { SpeedtestPanelFactory } from "../webviews/speedtest/speedtestPanelFactory";
78
import { DuplicateWorkspaceIpc } from "../workspace/duplicateWorkspaceIpc";
89

@@ -31,6 +32,7 @@ export class ServiceContainer implements vscode.Disposable {
3132
private readonly duplicateWorkspaceIpc: DuplicateWorkspaceIpc;
3233
private readonly oauthCallback: OAuthCallback;
3334
private readonly speedtestPanelFactory: SpeedtestPanelFactory;
35+
private readonly telemetryService: TelemetryService;
3436

3537
constructor(context: vscode.ExtensionContext) {
3638
this.logger = vscode.window.createOutputChannel("Coder", { log: true });
@@ -88,6 +90,7 @@ export class ServiceContainer implements vscode.Disposable {
8890
context.extensionUri,
8991
this.logger,
9092
);
93+
this.telemetryService = new TelemetryService(context, [], this.logger);
9194
}
9295

9396
getPathResolver(): PathResolver {
@@ -134,12 +137,18 @@ export class ServiceContainer implements vscode.Disposable {
134137
return this.speedtestPanelFactory;
135138
}
136139

137-
/**
138-
* Dispose of all services and clean up resources.
139-
*/
140-
dispose(): void {
140+
getTelemetryService(): TelemetryService {
141+
return this.telemetryService;
142+
}
143+
144+
/** Dispose logger last so telemetry teardown warnings still reach it. */
145+
async dispose(): Promise<void> {
141146
this.contextManager.dispose();
142-
this.logger.dispose();
143147
this.loginCoordinator.dispose();
148+
try {
149+
await this.telemetryService.dispose();
150+
} finally {
151+
this.logger.dispose();
152+
}
144153
}
145154
}

src/deployment/deploymentManager.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type MementoManager } from "../core/mementoManager";
55
import { type SecretsManager } from "../core/secretsManager";
66
import { type Logger } from "../logging/logger";
77
import { type OAuthSessionManager } from "../oauth/sessionManager";
8+
import { type TelemetryService } from "../telemetry/service";
89
import { type WorkspaceProvider } from "../workspace/workspacesProvider";
910

1011
import {
@@ -33,6 +34,7 @@ export class DeploymentManager implements vscode.Disposable {
3334
private readonly mementoManager: MementoManager;
3435
private readonly contextManager: ContextManager;
3536
private readonly logger: Logger;
37+
private readonly telemetryService: TelemetryService;
3638

3739
#deployment: Deployment | null = null;
3840
#authListenerDisposable: vscode.Disposable | undefined;
@@ -48,6 +50,7 @@ export class DeploymentManager implements vscode.Disposable {
4850
this.mementoManager = serviceContainer.getMementoManager();
4951
this.contextManager = serviceContainer.getContextManager();
5052
this.logger = serviceContainer.getLogger();
53+
this.telemetryService = serviceContainer.getTelemetryService();
5154
}
5255

5356
public static create(
@@ -124,6 +127,7 @@ export class DeploymentManager implements vscode.Disposable {
124127
user: deployment.user.username,
125128
});
126129
this.#deployment = { ...deployment };
130+
this.telemetryService.setDeploymentUrl(deployment.url);
127131

128132
// Updates client credentials
129133
if (deployment.token === undefined) {
@@ -155,6 +159,7 @@ export class DeploymentManager implements vscode.Disposable {
155159
this.#authListenerDisposable?.dispose();
156160
this.#authListenerDisposable = undefined;
157161
this.#deployment = null;
162+
this.telemetryService.setDeploymentUrl("");
158163

159164
await this.secretsManager.setCurrentDeployment(undefined);
160165
}

src/remote/remote.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -861,8 +861,8 @@ export class Remote {
861861
): vscode.Disposable {
862862
const titleMap = new Map(settings.map((s) => [s.setting, s.title]));
863863

864-
return watchConfigurationChanges(settings, (changedSettings) => {
865-
const changedTitles = changedSettings
864+
return watchConfigurationChanges(settings, (changes) => {
865+
const changedTitles = [...changes.keys()]
866866
.map((s) => titleMap.get(s))
867867
.filter((t) => t !== undefined);
868868

src/telemetry/event.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import * as os from "node:os";
2+
import * as vscode from "vscode";
3+
4+
import { toError } from "../error/errorUtils";
5+
6+
/** Telemetry level, mirrors `coder.telemetry.level`. Ordered: off < local. */
7+
export type TelemetryLevel = "off" | "local";
8+
9+
/** Caller properties. `result` is framework-managed on traced events. */
10+
export type CallerProperties = Record<string, string> & { result?: never };
11+
12+
/** Caller measurements. `durationMs` is framework-managed on traced events. */
13+
export type CallerMeasurements = Record<string, number> & {
14+
durationMs?: never;
15+
};
16+
17+
/** Session-stable resource attributes. Field names are inspired by OTel
18+
* resource attributes; they are camelCase TypeScript and not a 1:1 mapping. */
19+
export interface SessionContext {
20+
readonly extensionVersion: string;
21+
readonly machineId: string;
22+
readonly sessionId: string;
23+
readonly osType: string;
24+
readonly osVersion: string;
25+
readonly hostArch: string;
26+
readonly platformName: string;
27+
readonly platformVersion: string;
28+
}
29+
30+
/** Per-event context: session attributes plus the current deployment URL. */
31+
export interface TelemetryContext extends SessionContext {
32+
readonly deploymentUrl: string;
33+
}
34+
35+
export interface TelemetryEvent {
36+
readonly eventId: string;
37+
readonly eventName: string;
38+
readonly timestamp: string;
39+
readonly eventSequence: number;
40+
41+
readonly context: TelemetryContext;
42+
43+
readonly properties: Readonly<Record<string, string>>;
44+
readonly measurements: Readonly<Record<string, number>>;
45+
46+
/** Shared by all events in a trace. Maps to OTel `trace_id`. */
47+
readonly traceId?: string;
48+
/** Set on phase children only. Equals the parent event's `eventId`. Maps to OTel `parent_span_id`. */
49+
readonly parentEventId?: string;
50+
51+
readonly error?: Readonly<{
52+
message: string;
53+
type?: string;
54+
code?: string;
55+
}>;
56+
}
57+
58+
/**
59+
* Sink for telemetry events. `write` is sync and must buffer in memory; I/O
60+
* happens in `flush`/`dispose`. The service filters by `minLevel`; sinks can
61+
* still self-gate on other signals (e.g. deployment URL).
62+
*/
63+
export interface TelemetrySink {
64+
readonly name: string;
65+
readonly minLevel: TelemetryLevel;
66+
write(event: TelemetryEvent): void;
67+
flush(): Promise<void>;
68+
dispose(): Promise<void>;
69+
}
70+
71+
/** Build session attributes. `extensionVersion` falls back to `"unknown"`. */
72+
export function buildSession(ctx: vscode.ExtensionContext): SessionContext {
73+
// "unknown" only for malformed package.json or test fixtures missing `version`.
74+
const packageJson = ctx.extension.packageJSON as { version?: unknown };
75+
const extensionVersion =
76+
typeof packageJson.version === "string" ? packageJson.version : "unknown";
77+
78+
return {
79+
extensionVersion,
80+
machineId: vscode.env.machineId,
81+
sessionId: vscode.env.sessionId,
82+
osType: detectOsType(),
83+
osVersion: os.release(),
84+
hostArch: process.arch,
85+
platformName: vscode.env.appName,
86+
platformVersion: vscode.version,
87+
};
88+
}
89+
90+
/** Normalize a thrown value into the event's `error` block. */
91+
export function buildErrorBlock(
92+
value: unknown,
93+
): NonNullable<TelemetryEvent["error"]> {
94+
const err = toError(value);
95+
const rawCode = (value as { code?: unknown } | null | undefined)?.code;
96+
const hasCode = typeof rawCode === "string" || typeof rawCode === "number";
97+
return {
98+
message: err.message,
99+
...(err.name && err.name !== "Error" && { type: err.name }),
100+
...(hasCode && { code: String(rawCode) }),
101+
};
102+
}
103+
104+
// Node uses "win32" on Windows; OTel's os.type is "windows".
105+
function detectOsType(): string {
106+
return process.platform === "win32" ? "windows" : process.platform;
107+
}

0 commit comments

Comments
 (0)