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.localJsonl": {
"markdownDescription": "Tunables for the local JSONL telemetry sink. Active when `#coder.telemetry.level#` is at least `local`. Events are written to JSONL files under the extension's global storage directory. Missing or invalid fields fall back to defaults.",
"type": "object",
"additionalProperties": false,
"properties": {
"flushIntervalMs": {
"type": "number",
"minimum": 1000,
"default": 15000,
"markdownDescription": "Interval in milliseconds between scheduled flushes of the in-memory buffer to disk."
},
"flushBatchSize": {
"type": "number",
"minimum": 1,
"default": 100,
"markdownDescription": "Buffer size that triggers an early flush before the next 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 JSONL file before rotating to a new segment."
},
"maxAgeDays": {
"type": "number",
"minimum": 1,
"default": 30,
"markdownDescription": "Telemetry files older than this many days are deleted at activation."
},
"maxTotalBytes": {
"type": "number",
"minimum": 4096,
"default": 104857600,
"markdownDescription": "Total bytes across all telemetry files. Oldest files are deleted at activation until 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
10 changes: 10 additions & 0 deletions src/core/pathResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ export class PathResolver {
return path.join(this.basePath, "net");
}

/**
* Return the directory where local telemetry JSONL files are written.
*
* Files within this directory are managed by `LocalJsonlSink`, which
* creates the directory on activation if it does not already exist.
*/
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
88 changes: 10 additions & 78 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, {
fileType: "network info file",
match: (name) => name.endsWith(".json"),
pick: (files, now) => files.filter((f) => now - f.mtime > maxAgeMs),
});
}

/**
Expand All @@ -175,9 +106,10 @@ export class SshProcessMonitor implements vscode.Disposable {
maxAgeMs: number,
logger: Logger,
): Promise<void> {
await SshProcessMonitor.cleanupFiles(logDir, "log file", logger, {
filter: (name) => name.startsWith("coder-ssh") && name.endsWith(".log"),
select: (files, now) =>
await cleanupFiles(logDir, logger, {
fileType: "log file",
match: (name) => name.startsWith("coder-ssh") && name.endsWith(".log"),
pick: (files, now) =>
files
.toSorted((a, b) => a.mtime - b.mtime) // oldest first
.slice(0, -maxFilesToKeep) // keep the newest maxFilesToKeep
Expand Down
69 changes: 69 additions & 0 deletions src/settings/telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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_JSONL_SETTING = "coder.telemetry.localJsonl";
Comment thread
EhabY marked this conversation as resolved.

/** Telemetry level. Falls back to `local` for any invalid value. */
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 LocalJsonlConfig {
readonly flushIntervalMs: number;
readonly flushBatchSize: number;
readonly bufferLimit: number;
readonly maxFileBytes: number;
readonly maxAgeDays: number;
readonly maxTotalBytes: number;
}

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

// Mirrors the schema minimums in package.json.
const MINIMUMS: LocalJsonlConfig = {
flushIntervalMs: 1000,
flushBatchSize: 1,
bufferLimit: 10,
maxFileBytes: 4096,
maxAgeDays: 1,
maxTotalBytes: 4096,
};

/** Missing or below-minimum fields fall back to the default. */
export function readLocalJsonlConfig(
cfg: Pick<WorkspaceConfiguration, "get">,
): LocalJsonlConfig {
const raw = cfg.get(LOCAL_JSONL_SETTING);
const obj =
raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
const read = (key: keyof LocalJsonlConfig): number =>
numberAtLeast(obj[key], MINIMUMS[key], LOCAL_JSONL_DEFAULTS[key]);
return {
flushIntervalMs: read("flushIntervalMs"),
flushBatchSize: read("flushBatchSize"),
bufferLimit: read("bufferLimit"),
maxFileBytes: read("maxFileBytes"),
maxAgeDays: read("maxAgeDays"),
maxTotalBytes: read("maxTotalBytes"),
};
}

function numberAtLeast(
value: unknown,
minimum: number,
fallback: number,
): number {
return typeof value === "number" && value >= minimum ? value : fallback;
}
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