Skip to content
12 changes: 7 additions & 5 deletions src/configWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ export interface WatchedSetting {

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

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

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

if (!configValuesEqual(newValue, appliedValues.get(setting))) {
changedSettings.push(setting);
changes.set(setting, newValue);
appliedValues.set(setting, newValue);
}
}

if (changedSettings.length > 0) {
onChange(changedSettings);
if (changes.size > 0) {
onChange(changes);
}
});
}
Expand Down
19 changes: 14 additions & 5 deletions src/core/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as vscode from "vscode";
import { CoderApi } from "../api/coderApi";
import { LoginCoordinator } from "../login/loginCoordinator";
import { OAuthCallback } from "../oauth/oauthCallback";
import { TelemetryService } from "../telemetry/service";
import { SpeedtestPanelFactory } from "../webviews/speedtest/speedtestPanelFactory";
import { DuplicateWorkspaceIpc } from "../workspace/duplicateWorkspaceIpc";

Expand Down Expand Up @@ -31,6 +32,7 @@ export class ServiceContainer implements vscode.Disposable {
private readonly duplicateWorkspaceIpc: DuplicateWorkspaceIpc;
private readonly oauthCallback: OAuthCallback;
private readonly speedtestPanelFactory: SpeedtestPanelFactory;
private readonly telemetryService: TelemetryService;
Comment thread
EhabY marked this conversation as resolved.

constructor(context: vscode.ExtensionContext) {
this.logger = vscode.window.createOutputChannel("Coder", { log: true });
Expand Down Expand Up @@ -88,6 +90,7 @@ export class ServiceContainer implements vscode.Disposable {
context.extensionUri,
this.logger,
);
this.telemetryService = new TelemetryService(context, [], this.logger);
Comment thread
EhabY marked this conversation as resolved.
}

getPathResolver(): PathResolver {
Expand Down Expand Up @@ -134,12 +137,18 @@ export class ServiceContainer implements vscode.Disposable {
return this.speedtestPanelFactory;
}

/**
* Dispose of all services and clean up resources.
*/
dispose(): void {
getTelemetryService(): TelemetryService {
return this.telemetryService;
}

/** Dispose logger last so telemetry teardown warnings still reach it. */
async dispose(): Promise<void> {
this.contextManager.dispose();
this.logger.dispose();
this.loginCoordinator.dispose();
try {
await this.telemetryService.dispose();
} finally {
this.logger.dispose();
}
}
}
5 changes: 5 additions & 0 deletions src/deployment/deploymentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { type MementoManager } from "../core/mementoManager";
import { type SecretsManager } from "../core/secretsManager";
import { type Logger } from "../logging/logger";
import { type OAuthSessionManager } from "../oauth/sessionManager";
import { type TelemetryService } from "../telemetry/service";
import { type WorkspaceProvider } from "../workspace/workspacesProvider";

import {
Expand Down Expand Up @@ -33,6 +34,7 @@ export class DeploymentManager implements vscode.Disposable {
private readonly mementoManager: MementoManager;
private readonly contextManager: ContextManager;
private readonly logger: Logger;
private readonly telemetryService: TelemetryService;

#deployment: Deployment | null = null;
#authListenerDisposable: vscode.Disposable | undefined;
Expand All @@ -48,6 +50,7 @@ export class DeploymentManager implements vscode.Disposable {
this.mementoManager = serviceContainer.getMementoManager();
this.contextManager = serviceContainer.getContextManager();
this.logger = serviceContainer.getLogger();
this.telemetryService = serviceContainer.getTelemetryService();
}

public static create(
Expand Down Expand Up @@ -124,6 +127,7 @@ export class DeploymentManager implements vscode.Disposable {
user: deployment.user.username,
});
this.#deployment = { ...deployment };
this.telemetryService.setDeploymentUrl(deployment.url);

// Updates client credentials
if (deployment.token === undefined) {
Expand Down Expand Up @@ -155,6 +159,7 @@ export class DeploymentManager implements vscode.Disposable {
this.#authListenerDisposable?.dispose();
this.#authListenerDisposable = undefined;
this.#deployment = null;
this.telemetryService.setDeploymentUrl("");
Comment thread
EhabY marked this conversation as resolved.

await this.secretsManager.setCurrentDeployment(undefined);
}
Expand Down
4 changes: 2 additions & 2 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,8 +861,8 @@ export class Remote {
): vscode.Disposable {
const titleMap = new Map(settings.map((s) => [s.setting, s.title]));

return watchConfigurationChanges(settings, (changedSettings) => {
const changedTitles = changedSettings
return watchConfigurationChanges(settings, (changes) => {
const changedTitles = [...changes.keys()]
.map((s) => titleMap.get(s))
.filter((t) => t !== undefined);

Expand Down
107 changes: 107 additions & 0 deletions src/telemetry/event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import * as os from "node:os";
import * as vscode from "vscode";

import { toError } from "../error/errorUtils";

/** Telemetry level, mirrors `coder.telemetry.level`. Ordered: off < local. */
export type TelemetryLevel = "off" | "local";

/** Caller properties. `result` is framework-managed on traced events. */
export type CallerProperties = Record<string, string> & { result?: never };

/** Caller measurements. `durationMs` is framework-managed on traced events. */
export type CallerMeasurements = Record<string, number> & {
durationMs?: never;
};

/** Session-stable resource attributes. Field names are inspired by OTel
* resource attributes; they are camelCase TypeScript and not a 1:1 mapping. */
export interface SessionContext {
readonly extensionVersion: string;
readonly machineId: string;
readonly sessionId: string;
readonly osType: string;
readonly osVersion: string;
readonly hostArch: string;
readonly platformName: string;
readonly platformVersion: string;
}

/** Per-event context: session attributes plus the current deployment URL. */
export interface TelemetryContext extends SessionContext {
readonly deploymentUrl: string;
}

export interface TelemetryEvent {
readonly eventId: string;
readonly eventName: string;
readonly timestamp: string;
readonly eventSequence: number;

readonly context: TelemetryContext;

readonly properties: Readonly<Record<string, string>>;
readonly measurements: Readonly<Record<string, number>>;

/** Shared by all events in a trace. Maps to OTel `trace_id`. */
readonly traceId?: string;
/** Set on phase children only. Equals the parent event's `eventId`. Maps to OTel `parent_span_id`. */
readonly parentEventId?: string;

readonly error?: Readonly<{
message: string;
type?: string;
code?: string;
}>;
}

/**
* Sink for telemetry events. `write` is sync and must buffer in memory; I/O
* happens in `flush`/`dispose`. The service filters by `minLevel`; sinks can
* still self-gate on other signals (e.g. deployment URL).
*/
export interface TelemetrySink {
readonly name: string;
readonly minLevel: TelemetryLevel;
write(event: TelemetryEvent): void;
flush(): Promise<void>;
dispose(): Promise<void>;
}

/** Build session attributes. `extensionVersion` falls back to `"unknown"`. */
export function buildSession(ctx: vscode.ExtensionContext): SessionContext {
// "unknown" only for malformed package.json or test fixtures missing `version`.
const packageJson = ctx.extension.packageJSON as { version?: unknown };
const extensionVersion =
typeof packageJson.version === "string" ? packageJson.version : "unknown";
Comment thread
EhabY marked this conversation as resolved.

return {
extensionVersion,
machineId: vscode.env.machineId,
sessionId: vscode.env.sessionId,
osType: detectOsType(),
osVersion: os.release(),
hostArch: process.arch,
platformName: vscode.env.appName,
platformVersion: vscode.version,
};
}

/** Normalize a thrown value into the event's `error` block. */
export function buildErrorBlock(
value: unknown,
): NonNullable<TelemetryEvent["error"]> {
const err = toError(value);
const rawCode = (value as { code?: unknown } | null | undefined)?.code;
const hasCode = typeof rawCode === "string" || typeof rawCode === "number";
return {
message: err.message,
...(err.name && err.name !== "Error" && { type: err.name }),
...(hasCode && { code: String(rawCode) }),
};
}

// Node uses "win32" on Windows; OTel's os.type is "windows".
function detectOsType(): string {
return process.platform === "win32" ? "windows" : process.platform;
Comment thread
EhabY marked this conversation as resolved.
}
Loading