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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
- Local telemetry now records authentication refresh and recovery prompts.
- Local telemetry now records workspace and agent state transitions with
observed durations.
- The `Coder: Export Telemetry` command writes locally recorded telemetry to
a file you choose, as a JSON array or an OTLP/JSON zip, for a selected date
range. The OTLP zip includes a `manifest.json` summarizing the export: date
range, source file and event counts, per-signal record counts, and the
telemetry schema version.
- Path-like settings (`coder.binaryDestination`, `coder.tlsCertFile`,
`coder.tlsKeyFile`, `coder.tlsCaFile`, `coder.tlsAltHost`,
`coder.proxyLogDirectory`) and items in `coder.globalFlags` now support
Expand Down
4 changes: 3 additions & 1 deletion src/instrumentation/websocket.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizeRoute } from "../logging/routeNormalization";

import type { CallerProperties } from "../telemetry/event";
import type { TelemetryReporter } from "../telemetry/reporter";
import type { ConnectionState } from "../websocket/reconnectingWebSocket";
Expand Down Expand Up @@ -82,7 +84,7 @@ export class WebSocketTelemetry {
this.#connectStartedAtMs = undefined;
this.#telemetry.log(
"connection.opened",
{ route },
{ route: normalizeRoute(route) },
{ connectDurationMs: now - start },
);
this.#finishReconnect({ result: "success" });
Expand Down
88 changes: 2 additions & 86 deletions src/logging/httpRequestsTelemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,15 @@ import {
} from "../telemetry/reporter";

import { formatMethod } from "./formatters";
import { normalizeRoute } from "./routeNormalization";

import type { Disposable } from "vscode";

import type { RequestConfigWithMeta } from "./types";

const EVENT_NAME = "http.requests";
const UNKNOWN_ROUTE = "<unknown>";
const WINDOW_SECONDS = 60;

const ID_PLACEHOLDER = "{id}";
const NAME_PLACEHOLDER = "{name}";

const ROUTE_NORMALIZATION_RULES: ReadonlyArray<readonly string[]> = [
"api/v2/users/{name}/workspace/{name}",
"api/v2/users/{name}/keys/{id}",
"api/v2/users/{name}",
"api/v2/tasks/{name}/{id}",
"api/v2/tasks/{name}",
"api/v2/organizations/{id}/templates/{name}/versions/{name}",
"api/v2/organizations/{id}/templates/{name}",
"api/v2/organizations/{id}/groups/{name}",
"api/v2/organizations/{id}/members/{name}",
"api/v2/organizations/{id}",
"api/v2/aibridge/sessions/{id}",
"api/v2/files/{id}",
"api/v2/groups/{id}",
"api/v2/licenses/{id}",
"api/v2/oauth2-provider/apps/{id}",
"api/v2/templates/{id}",
"api/v2/templateversions/{id}",
"api/v2/workspaceagents/{id}",
"api/v2/workspacebuilds/{id}",
"api/v2/workspaces/{id}/builds/{id}",
"api/v2/workspaces/{id}",
].map((rule) => rule.split("/"));

interface HttpRequestBucket {
count1xx: number;
count2xx: number;
Expand Down Expand Up @@ -115,7 +88,7 @@ export class HttpRequestsTelemetry implements Disposable {
}

const method = formatMethod(config.method);
const route = normalizeHttpRoute(config.url, config.baseURL);
const route = normalizeRoute(config.url, config.baseURL);
const bucket = this.#getOrCreateBucket(method, route);

const durationMs = elapsedMs(config);
Expand Down Expand Up @@ -206,63 +179,6 @@ export class HttpRequestsTelemetry implements Disposable {
}
}

export function normalizeHttpRoute(
url: string | undefined,
baseURL?: string,
): string {
if (!url) {
return UNKNOWN_ROUTE;
}

const segments = parsePathSegments(url, baseURL);
if (segments.length === 0) {
return UNKNOWN_ROUTE;
}

for (const rule of ROUTE_NORMALIZATION_RULES) {
const normalized = normalizeByRule(segments, rule);
if (normalized) {
return normalized;
}
}
// No matching rule. Pass through; add a rule above if cardinality grows.
return `/${segments.join("/")}`;
}

function normalizeByRule(
segments: readonly string[],
rule: readonly string[],
): string | undefined {
if (segments.length < rule.length) {
return undefined;
}

const normalized: string[] = [];
for (const [index, ruleSegment] of rule.entries()) {
if (ruleSegment === ID_PLACEHOLDER || ruleSegment === NAME_PLACEHOLDER) {
normalized.push(ruleSegment);
continue;
}
if (segments[index] !== ruleSegment) {
return undefined;
}
normalized.push(segments[index]);
}

// Trailing segments pass through. If a tail can hold an ID, add a rule.
return `/${[...normalized, ...segments.slice(rule.length)].join("/")}`;
}

function parsePathSegments(url: string, baseURL?: string): string[] {
try {
return new URL(url, baseURL ?? "http://coder.invalid").pathname
.split("/")
.filter(Boolean);
} catch {
return [];
}
}

function elapsedMs(
config: RequestConfigWithMeta | undefined,
): number | undefined {
Expand Down
130 changes: 130 additions & 0 deletions src/logging/routeNormalization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Normalizes request and websocket routes into low-cardinality telemetry
* labels. Drops the query/fragment (which can carry tokens) and bounds
* cardinality by collapsing ids and bucketing unmatched routes, so even an
* unseen route is safe to emit.
*/

const UNKNOWN_ROUTE = "<unknown>";
const ID_PLACEHOLDER = "{id}";
const NAME_PLACEHOLDER = "{name}";
/** Tail marker for routes with no matching template. */
const BUCKET_PLACEHOLDER = "{*}";
/** Segments kept verbatim before the bucket marker on unmatched routes. */
const UNMATCHED_PREFIX_SEGMENTS = 3;

/** Any-version UUID; version and variant nibbles are unenforced so UUIDv7 ids collapse too. */
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const INTEGER_PATTERN = /^\d+$/;

/**
* Templates refine name segments (usernames, workspace/template names) that
* id collapsing misses. Precision only: a missing rule never risks
* cardinality, since unmatched routes still collapse ids and bucket.
*/
const ROUTE_NORMALIZATION_RULES: ReadonlyArray<readonly string[]> = [
"api/v2/users/{name}/workspace/{name}",
"api/v2/users/{name}/keys/{id}",
"api/v2/users/{name}",
"api/v2/tasks/{name}/{id}",
"api/v2/tasks/{name}",
"api/v2/organizations/{id}/templates/{name}/versions/{name}",
"api/v2/organizations/{id}/templates/{name}",
"api/v2/organizations/{id}/groups/{name}",
"api/v2/organizations/{id}/members/{name}",
"api/v2/organizations/{id}",
"api/v2/aibridge/sessions/{id}",
"api/v2/files/{id}",
"api/v2/groups/{id}",
"api/v2/licenses/{id}",
"api/v2/oauth2-provider/apps/{id}",
"api/v2/templates/{id}",
"api/v2/templateversions/{id}",
"api/v2/workspaceagents/{id}",
"api/v2/workspacebuilds/{id}",
"api/v2/workspaces/{id}/builds/{id}",
"api/v2/workspaces/{id}",
].map((rule) => rule.split("/"));

/**
* Normalizes `url` (optionally resolved against `baseURL`) to a stable route
* label. Returns `<unknown>` for missing or unparseable input.
*/
export function normalizeRoute(
url: string | undefined,
baseURL?: string,
): string {
if (!url) {
return UNKNOWN_ROUTE;
}

const segments = parsePathSegments(url, baseURL);
if (segments.length === 0) {
return UNKNOWN_ROUTE;
}

const collapsed = segments.map(collapseIdSegment);
for (const rule of ROUTE_NORMALIZATION_RULES) {
const normalized = normalizeByRule(collapsed, rule);
if (normalized) {
return normalized;
}
}
return bucketUnmatchedRoute(collapsed);
}

/** Collapses UUID and integer segments to `{id}` to bound cardinality. */
function collapseIdSegment(segment: string): string {
return UUID_PATTERN.test(segment) || INTEGER_PATTERN.test(segment)
? ID_PLACEHOLDER
: segment;
}

function normalizeByRule(
segments: readonly string[],
rule: readonly string[],
): string | undefined {
if (segments.length < rule.length) {
return undefined;
}

const normalized: string[] = [];
for (const [index, ruleSegment] of rule.entries()) {
if (ruleSegment === ID_PLACEHOLDER || ruleSegment === NAME_PLACEHOLDER) {
normalized.push(ruleSegment);
continue;
}
if (segments[index] !== ruleSegment) {
return undefined;
}
normalized.push(segments[index]);
}

// Trailing segments pass through with ids already collapsed; add a rule
// above if a tail can hold a name.
return `/${[...normalized, ...segments.slice(rule.length)].join("/")}`;
}

/**
* Bucket for unmatched routes: keep a short prefix and collapse the rest, so
* unseen routes stay bounded even when their variable segments are names.
*/
function bucketUnmatchedRoute(segments: readonly string[]): string {
if (segments.length <= UNMATCHED_PREFIX_SEGMENTS) {
return `/${segments.join("/")}`;
}
const prefix = segments.slice(0, UNMATCHED_PREFIX_SEGMENTS).join("/");
return `/${prefix}/${BUCKET_PLACEHOLDER}`;
}

/** Path segments only; `pathname` drops the query and fragment so tokens never reach telemetry. */
function parsePathSegments(url: string, baseURL?: string): string[] {
try {
return new URL(url, baseURL ?? "http://coder.invalid").pathname
.split("/")
.filter(Boolean);
} catch {
return [];
}
}
10 changes: 8 additions & 2 deletions src/telemetry/export/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { createExportWriter } from "./writers";

import type { Logger } from "../../logging/logger";
import type { TelemetryContext } from "../event";
import type { FlushStatus } from "../service";

const REVEAL_ACTION = "Reveal in File Explorer";

Expand All @@ -29,7 +30,7 @@ const PROGRESS_OPTIONS = {
export async function runExportTelemetryCommand(
telemetryDir: string,
logger: Logger,
flushTelemetry: () => Promise<void>,
flushTelemetry: () => Promise<FlushStatus>,
context: TelemetryContext,
): Promise<void> {
const choice = await promptForExport();
Expand Down Expand Up @@ -58,13 +59,18 @@ export async function runExportTelemetryCommand(
/** Wires the pipeline's host hooks to the progress UI and the logger. */
function exportRuntime(
{ progress, signal }: ProgressContext,
flushTelemetry: () => Promise<void>,
flushTelemetry: () => Promise<FlushStatus>,
logger: Logger,
): ExportRuntime {
return {
signal,
flushTelemetry,
report: (message) => progress.report({ message }),
// Warn but keep going: the export still reflects what reached disk.
onFlushIncomplete: () =>
void vscode.window.showWarningMessage(
"Some recent telemetry could not be flushed; this export may be missing the latest events.",
),
onCleanupError: (err, target) =>
logger.warn("Failed to clean up after telemetry export", target, err),
};
Expand Down
20 changes: 14 additions & 6 deletions src/telemetry/export/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { throwIfAborted } from "../../error/errorUtils";
import { listTelemetryFilesForRange, streamTelemetryEvents } from "./files";

import type { TelemetryEvent } from "../event";
import type { FlushStatus } from "../service";

import type { TelemetryDateRange } from "./range";
import type { ExportWriter } from "./writers/types";
Expand All @@ -22,8 +23,10 @@ export interface ExportRequest {
*/
export interface ExportRuntime {
readonly signal: AbortSignal;
readonly flushTelemetry: () => Promise<void>;
readonly flushTelemetry: () => Promise<FlushStatus>;
readonly report: (message: string) => void;
/** The pre-export flush did not fully succeed, so recent events may be missing. */
readonly onFlushIncomplete: () => void;
/** A temp file, staging dir, or empty export could not be removed (caller logs). */
readonly onCleanupError: (err: unknown, target: string) => void;
}
Expand All @@ -38,8 +41,11 @@ export async function collectTelemetryExport(
runtime: ExportRuntime,
): Promise<number> {
runtime.report("Flushing buffered events...");
await runtime.flushTelemetry();
const flushStatus = await runtime.flushTelemetry();
throwIfAborted(runtime.signal);
if (!flushStatus.ok) {
runtime.onFlushIncomplete();
}

runtime.report("Locating telemetry files...");
const filePaths = await listTelemetryFilesForRange(
Expand All @@ -55,10 +61,12 @@ export async function collectTelemetryExport(
streamTelemetryEvents(filePaths, request.range),
runtime.signal,
);
const eventCount = await request.writer(request.outputPath, events, {
signal: runtime.signal,
onCleanupError: runtime.onCleanupError,
});
const eventCount = await request.writer(
request.outputPath,
events,
{ range: request.range, sourceFiles: filePaths.length },
{ signal: runtime.signal, onCleanupError: runtime.onCleanupError },
);
if (eventCount === 0) {
await removeEmptyExport(request.outputPath, runtime.onCleanupError);
}
Expand Down
11 changes: 10 additions & 1 deletion src/telemetry/export/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,5 +152,14 @@ async function promptSavePath(
filters,
title: "Save Telemetry Export",
});
return uri?.fsPath;
if (!uri) {
return undefined;
}
if (uri.scheme !== "file") {
vscode.window.showErrorMessage(
"Telemetry can only be exported to a local file. The selected location is not a local file path.",
);
return undefined;
}
return uri.fsPath;
}
Loading