Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,76 @@
"Logs everything from *headers* plus request and response bodies (may include sensitive data)"
],
"default": "basic"
},
"coder.telemetry.level": {
"markdownDescription": "Controls Coder extension telemetry collection. Used to diagnose extension issues.",
"type": "string",
"enum": [
"off",
"local"
],
"markdownEnumDescriptions": [
"Disable telemetry collection.",
"Record events on this machine only."
],
"default": "local",
"tags": [
"telemetry"
]
},
"coder.telemetry.local": {
"markdownDescription": "Tunables for the local telemetry sink, which writes events as JSON Lines under the extension's global storage. Used when `#coder.telemetry.level#` is `local`. Missing or invalid fields fall back to defaults.",
"type": "object",
"additionalProperties": false,
"properties": {
"flushIntervalMs": {
"type": "number",
"minimum": 1000,
"default": 15000,
"markdownDescription": "How often, in milliseconds, buffered events are written to disk."
},
"flushBatchSize": {
"type": "number",
"minimum": 1,
"default": 100,
"markdownDescription": "Number of buffered events that triggers an immediate write, ahead of the scheduled interval."
},
"bufferLimit": {
"type": "number",
"minimum": 10,
"default": 500,
"markdownDescription": "Maximum number of events held in memory. Oldest events are dropped on overflow. Should be at least `flushBatchSize`."
},
"maxFileBytes": {
"type": "number",
"minimum": 4096,
"default": 5242880,
"markdownDescription": "Maximum size, in bytes, of a single telemetry file before the sink rotates to a new one."
},
"maxAgeDays": {
"type": "number",
"minimum": 1,
"default": 30,
"markdownDescription": "Telemetry files older than this many days are deleted when the extension activates."
},
"maxTotalBytes": {
"type": "number",
"minimum": 4096,
"default": 104857600,
"markdownDescription": "Cap, in bytes, on the combined size of telemetry files. Oldest files are deleted on activation until the total is under the cap."
}
},
"default": {
"flushIntervalMs": 15000,
"flushBatchSize": 100,
"bufferLimit": 500,
"maxFileBytes": 5242880,
"maxAgeDays": 30,
"maxTotalBytes": 104857600
},
"tags": [
"telemetry"
]
}
}
},
Expand Down
14 changes: 13 additions & 1 deletion src/core/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CoderApi } from "../api/coderApi";
import { LoginCoordinator } from "../login/loginCoordinator";
import { OAuthCallback } from "../oauth/oauthCallback";
import { TelemetryService } from "../telemetry/service";
import { LocalJsonlSink } from "../telemetry/sinks/localJsonlSink";
import { SpeedtestPanelFactory } from "../webviews/speedtest/speedtestPanelFactory";
import { DuplicateWorkspaceIpc } from "../workspace/duplicateWorkspaceIpc";

Expand Down Expand Up @@ -90,7 +91,18 @@ export class ServiceContainer implements vscode.Disposable {
context.extensionUri,
this.logger,
);
this.telemetryService = new TelemetryService(context, [], this.logger);
const localJsonlSink = LocalJsonlSink.start(
{
baseDir: this.pathResolver.getTelemetryPath(),
sessionId: vscode.env.sessionId,
Comment thread
EhabY marked this conversation as resolved.
},
this.logger,
);
this.telemetryService = new TelemetryService(
context,
[localJsonlSink],
this.logger,
);
}

getPathResolver(): PathResolver {
Expand Down
7 changes: 7 additions & 0 deletions src/core/pathResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export class PathResolver {
return path.join(this.basePath, "net");
}

/**
* Return the directory where telemetry files are written.
*/
public getTelemetryPath(): string {
return path.join(this.basePath, "telemetry");
}

/**
* Return the proxy log directory from the `coder.proxyLogDirectory` setting
* or the `CODER_SSH_LOG_DIR` environment variable, falling back to the `log`
Expand Down
84 changes: 8 additions & 76 deletions src/remote/sshProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as path from "node:path";
import * as vscode from "vscode";

import { findPort } from "../util";
import { cleanupFiles } from "../util/fileCleanup";

import { NetworkStatusReporter } from "./networkStatus";

Expand Down Expand Up @@ -80,72 +81,6 @@ export class SshProcessMonitor implements vscode.Disposable {
private lastStaleSearchTime = 0;
private readonly reporter: NetworkStatusReporter;

/**
* Helper to clean up files in a directory.
* Stats files in parallel, applies selection criteria, then deletes in parallel.
*/
private static async cleanupFiles(
dir: string,
fileType: string,
logger: Logger,
options: {
filter: (name: string) => boolean;
select: (
files: Array<{ name: string; mtime: number }>,
now: number,
) => Array<{ name: string }>;
},
): Promise<void> {
try {
const now = Date.now();
const files = await fs.readdir(dir);

// Gather file stats in parallel
const withStats = await Promise.all(
files.filter(options.filter).map(async (name) => {
try {
const stats = await fs.stat(path.join(dir, name));
return { name, mtime: stats.mtime.getTime() };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
logger.debug(`Failed to stat ${fileType} ${name}`, error);
}
return null;
}
}),
);

const toDelete = options.select(
withStats.filter((f) => f !== null),
now,
);

// Delete files in parallel
const results = await Promise.all(
toDelete.map(async (file) => {
try {
await fs.unlink(path.join(dir, file.name));
return file.name;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
logger.debug(`Failed to delete ${fileType} ${file.name}`, error);
}
return null;
}
}),
);

const deletedFiles = results.filter((name) => name !== null);
if (deletedFiles.length > 0) {
logger.debug(
`Cleaned up ${deletedFiles.length} ${fileType}(s): ${deletedFiles.join(", ")}`,
);
}
} catch {
// Directory may not exist yet, ignore
}
}

/**
* Cleans up network info files older than the specified age.
*/
Expand All @@ -154,15 +89,11 @@ export class SshProcessMonitor implements vscode.Disposable {
maxAgeMs: number,
logger: Logger,
): Promise<void> {
await SshProcessMonitor.cleanupFiles(
networkInfoPath,
"network info file",
logger,
{
filter: (name) => name.endsWith(".json"),
select: (files, now) => files.filter((f) => now - f.mtime > maxAgeMs),
},
);
await cleanupFiles(networkInfoPath, logger, {
label: "network info file",
filter: (name) => name.endsWith(".json"),
select: (files, now) => files.filter((f) => now - f.mtime > maxAgeMs),
});
}

/**
Expand All @@ -175,7 +106,8 @@ export class SshProcessMonitor implements vscode.Disposable {
maxAgeMs: number,
logger: Logger,
): Promise<void> {
await SshProcessMonitor.cleanupFiles(logDir, "log file", logger, {
await cleanupFiles(logDir, logger, {
label: "log file",
filter: (name) => name.startsWith("coder-ssh") && name.endsWith(".log"),
select: (files, now) =>
files
Expand Down
68 changes: 68 additions & 0 deletions src/settings/telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { WorkspaceConfiguration } from "vscode";
Comment thread
EhabY marked this conversation as resolved.

import type { TelemetryLevel } from "../telemetry/event";

export const TELEMETRY_LEVEL_SETTING = "coder.telemetry.level";
export const LOCAL_SINK_SETTING = "coder.telemetry.local";

/** Telemetry level. Falls back to `local` for unknown or invalid values. */
export function readTelemetryLevel(
cfg: Pick<WorkspaceConfiguration, "get">,
): TelemetryLevel {
const value = cfg.get<string>(TELEMETRY_LEVEL_SETTING);
return value === "off" || value === "local" ? value : "local";
}

export interface LocalSinkConfig {
readonly flushIntervalMs: number;
readonly flushBatchSize: number;
readonly bufferLimit: number;
readonly maxFileBytes: number;
readonly maxAgeDays: number;
readonly maxTotalBytes: number;
}

export const LOCAL_SINK_DEFAULTS: LocalSinkConfig = {
flushIntervalMs: 15_000,
flushBatchSize: 100,
bufferLimit: 500,
maxFileBytes: 5 * 1024 * 1024,
maxAgeDays: 30,
maxTotalBytes: 100 * 1024 * 1024,
};

// Defense in depth: VS Code does not enforce JSON schema at runtime, so users
// can drop in any value via settings.json. Mirrors the minimums in package.json.
const LOCAL_SINK_MINIMUMS: LocalSinkConfig = {
flushIntervalMs: 1000,
flushBatchSize: 1,
bufferLimit: 10,
maxFileBytes: 4096,
maxAgeDays: 1,
maxTotalBytes: 4096,
};

/** Per-field: missing, non-numeric, or below-minimum values fall back to defaults. */
export function readLocalSinkConfig(
cfg: Pick<WorkspaceConfiguration, "get">,
): LocalSinkConfig {
const raw = cfg.get(LOCAL_SINK_SETTING);
const obj =
raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {};
const read = (key: keyof LocalSinkConfig): number => {
const value = obj[key];
return typeof value === "number" && value >= LOCAL_SINK_MINIMUMS[key]
? value
: LOCAL_SINK_DEFAULTS[key];
};
return {
flushIntervalMs: read("flushIntervalMs"),
flushBatchSize: read("flushBatchSize"),
bufferLimit: read("bufferLimit"),
maxFileBytes: read("maxFileBytes"),
maxAgeDays: read("maxAgeDays"),
maxTotalBytes: read("maxTotalBytes"),
};
}
28 changes: 12 additions & 16 deletions src/telemetry/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import * as vscode from "vscode";

import { watchConfigurationChanges } from "../configWatcher";
import { type Logger } from "../logging/logger";
import {
TELEMETRY_LEVEL_SETTING,
readTelemetryLevel,
} from "../settings/telemetry";

import {
buildSession,
Expand All @@ -15,13 +19,14 @@ import {
} from "./event";
import { NOOP_SPAN, type Span } from "./span";

const TELEMETRY_LEVEL_SETTING = "coder.telemetry.level";

const LEVEL_ORDER: Readonly<Record<TelemetryLevel, number>> = {
off: 0,
local: 1,
};

const readLevel = (): TelemetryLevel =>
readTelemetryLevel(vscode.workspace.getConfiguration());

/** Trace context shared by all events in one trace. */
interface SpanOptions {
traceId: string;
Expand Down Expand Up @@ -56,11 +61,13 @@ export class TelemetryService implements vscode.Disposable {
this.#configWatcher = watchConfigurationChanges(
[{ setting: TELEMETRY_LEVEL_SETTING, getValue: readLevel }],
(changes) => {
const raw = changes.get(TELEMETRY_LEVEL_SETTING);
if (!isTelemetryLevel(raw)) {
const next = changes.get(TELEMETRY_LEVEL_SETTING) as
Comment thread
EhabY marked this conversation as resolved.
| TelemetryLevel
| undefined;
if (!next) {
return;
}
this.#applyLevelChange(raw).catch((err) => {
this.#applyLevelChange(next).catch((err) => {
this.logger.warn("Telemetry level change failed", err);
});
},
Expand Down Expand Up @@ -277,14 +284,3 @@ export class TelemetryService implements vscode.Disposable {
}
}
}

function readLevel(): TelemetryLevel {
const value = vscode.workspace
.getConfiguration()
.get<string>(TELEMETRY_LEVEL_SETTING);
return isTelemetryLevel(value) ? value : "local";
}

function isTelemetryLevel(value: unknown): value is TelemetryLevel {
return value === "off" || value === "local";
}
Loading
Loading