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
326 changes: 240 additions & 86 deletions src/core/cliManager.ts

Large diffs are not rendered by default.

224 changes: 224 additions & 0 deletions src/instrumentation/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { isAbortError } from "../error/errorUtils";

import { CredentialFileError } from "./credentials";

import type { CallerPropertyValue } from "../telemetry/event";
import type { TelemetryService } from "../telemetry/service";
import type { Span } from "../telemetry/span";

export type CliCacheSource = "file-path" | "directory" | "not-found";
export type CliDownloadReason = "missing" | "version_mismatch" | "unreadable";
export type CliDownloadAction = "download" | "fallback" | "blocked";
export type CliCredentialSource = "session_token" | "empty_token";
export type CliResolveOutcome =
| "cache_hit"
| "downloaded"
| "lock_wait_cache_hit"
| "download_disabled_fallback"
| "fallback_to_existing_binary";
export type CliVersionCheckOutcome =
| "missing"
| "match"
| "mismatch"
| "unreadable";
export type CliConfigureFailureCategory =
| "cancelled"
| "filesystem"
| "credential_store"
| "unknown";
export type CliResolveFailureCategory =
| "downloads_disabled"
| "download"
| "fallback_declined";

interface CliConfigureOptions {
readonly silent: boolean;
readonly credentialSource: CliCredentialSource;
}

export class CliDownloadsDisabledError extends Error {
public constructor() {
super("Unable to download CLI because downloads are disabled");
this.name = "CliDownloadsDisabledError";
}
}

export class CliFallbackDeclinedError extends Error {
public constructor(cause: unknown) {
super(
cause instanceof Error ? cause.message : "CLI binary fallback declined",
{
cause,
},
);
this.name = "CliFallbackDeclinedError";
}
}

export class CliTelemetry {
public constructor(private readonly telemetry: TelemetryService) {}

public traceResolve<T>(
fn: (trace: CliResolveTrace) => Promise<T>,
): Promise<T> {
return this.telemetry.trace("cli.resolve", (span) =>
fn(new CliResolveTrace(span)),
);
}

public traceDownload<T>(
reason: CliDownloadReason,
fn: (span: Span) => Promise<T>,
): Promise<T> {
return this.telemetry.trace("cli.download", fn, { reason });
}

public traceConfigure<T>(
options: CliConfigureOptions,
fn: (trace: CliConfigureTrace) => Promise<T>,
): Promise<T> {
return this.telemetry.trace("cli.configure", (span) => {
span.setProperty("silent", options.silent);
span.setProperty("credential_source", options.credentialSource);
return fn(new CliConfigureTrace(span));
});
}
}

export class CliResolveTrace {
public constructor(private readonly span: Span) {}

public setOutcome(outcome: CliResolveOutcome): void {
this.span.setProperty("outcome", outcome);
}

public setFailure(category: CliResolveFailureCategory | "unknown"): void {
this.span.setProperty("failure_category", category);
}

public cacheLookup<T extends { readonly source: CliCacheSource }>(
fn: () => Promise<T>,
): Promise<T> {
return this.tracedPhase("cache_lookup", fn, (r) => r.source, {
child: "source",
parent: "cache_source",
});
}

public versionCheck<T extends { readonly outcome: CliVersionCheckOutcome }>(
fn: () => Promise<T>,
): Promise<T> {
return this.tracedPhase("version_check", fn, (r) => r.outcome, {
child: "outcome",
parent: "version_check",
});
}

public lockWait<T extends { readonly waited: boolean }>(
fn: () => Promise<T>,
): Promise<T> {
return this.tracedPhase("lock_wait", fn, (r) => r.waited, {
child: "waited",
});
}

public lockRecheck<T extends { readonly outcome: CliVersionCheckOutcome }>(
fn: () => Promise<T>,
): Promise<T> {
return this.tracedPhase("lock_wait_recheck", fn, (r) => r.outcome, {
child: "outcome",
});
}

public downloadDecision(
reason: CliDownloadReason,
action: CliDownloadAction,
): Promise<void> {
this.span.setProperty("download_reason", reason);
return this.span.phase("download_decision", () => Promise.resolve(), {
reason,
outcome: action,
});
}

public async fallback<T>(error: unknown, fn: () => Promise<T>): Promise<T> {
const result = await this.span.phase(
"fallback_to_existing_binary",
async (span) => {
try {
const result = await fn();
span.setProperty("failure_category", categorizeResolveFailure(error));
return result;
} catch (fallbackError) {
span.setProperty(
"failure_category",
categorizeResolveFailure(fallbackError),
);
throw fallbackError;
}
},
);
this.setOutcome("fallback_to_existing_binary");
return result;
}

/**
* Run `fn` as a child phase tagged with `select(result)`, mirroring it onto
* the parent when `keys.parent` is given.
*/
private async tracedPhase<T>(
name: string,
fn: () => Promise<T>,
select: (result: T) => CallerPropertyValue,
keys: { readonly child: string; readonly parent?: string },
): Promise<T> {
const result = await this.span.phase(name, async (child) => {
const value = await fn();
child.setProperty(keys.child, select(value));
return value;
});
if (keys.parent) {
this.span.setProperty(keys.parent, select(result));
}
return result;
}
}

export class CliConfigureTrace {
public constructor(private readonly span: Span) {}

public cancel(): void {
this.span.setProperty("failure_category", "cancelled");
this.span.markAborted();
}

public fail(error: unknown): void {
this.span.setProperty(
"failure_category",
categorizeConfigureFailure(error),
);
}
}

function categorizeConfigureFailure(
error: unknown,
): CliConfigureFailureCategory {
if (isAbortError(error)) {
return "cancelled";
}
// A CredentialFileError is a file-write failure; anything else is keyring/CLI.
if (error instanceof CredentialFileError) {
return "filesystem";
}
return error instanceof Error ? "credential_store" : "unknown";
}

function categorizeResolveFailure(error: unknown): CliResolveFailureCategory {
if (error instanceof CliDownloadsDisabledError) {
return "downloads_disabled";
}
if (error instanceof CliFallbackDeclinedError) {
return "fallback_declined";
}
return "download";
}
11 changes: 9 additions & 2 deletions src/instrumentation/remoteSetup.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import type { TelemetryService } from "../telemetry/service";

export type RemoteSetupPhase =
| "cli_resolve"
| "cli_configure"
| "compatibility_check"
| "workspace_lookup"
| "workspace_monitor_setup"
| "workspace_ready"
| "resolve_agent"
| "ssh_config_write";
| "agent_resolve"
| "ssh_config_write"
| "ssh_monitor_setup"
| "connection_handoff";

/** Reason for a non-throwing early exit from `remote.setup`. */
export type RemoteSetupOutcome = "workspace_not_found" | "incompatible_server";

/** Helpers scoped to the remote.setup trace's lifetime. */
export interface RemoteSetupTracer {
/** Emit a typed child phase of `remote.setup`. */
phase<T>(name: RemoteSetupPhase, fn: () => T | PromiseLike<T>): Promise<T>;
/** Mark this setup as aborted with a typed reason; emits as `outcome` on the parent event. */
markAborted(reason: RemoteSetupOutcome): void;
Expand Down
23 changes: 17 additions & 6 deletions src/instrumentation/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface ReconnectCycle {
readonly startMs: number;
readonly reason: ConnectionStateReason;
attempts: number;
maxBackoffMs: number;
}

interface DropOptions {
Expand Down Expand Up @@ -85,7 +86,7 @@ export class WebSocketTelemetry {
this.#telemetry.log(
"connection.opened",
{ route: normalizeRoute(route) },
{ connectDurationMs: now - start },
{ connect_duration_ms: now - start },
);
this.#finishReconnect({ result: "success" });
}
Expand All @@ -104,10 +105,10 @@ export class WebSocketTelemetry {

const properties: CallerProperties = { cause };
if (closeCode !== undefined) {
properties.closeCode = closeCode;
properties.close_code = closeCode;
}
const measurements = {
connectionDurationMs: performance.now() - openedAtMs,
connection_duration_ms: performance.now() - openedAtMs,
};
if (error === undefined) {
this.#telemetry.log("connection.dropped", properties, measurements);
Expand Down Expand Up @@ -136,6 +137,7 @@ export class WebSocketTelemetry {
startMs: performance.now(),
reason,
attempts: 0,
maxBackoffMs: 0,
};
}

Expand All @@ -146,9 +148,17 @@ export class WebSocketTelemetry {
}

/** Drop and (re)open a reconnect cycle. */
public retrying(reason: ConnectionStateReason, options: DropOptions): void {
public retrying(
reason: ConnectionStateReason,
options: DropOptions,
backoffMs: number,
): void {
this.dropped(options.cause, options.code, options.error);
this.reconnectStarted(reason);
const cycle = this.#reconnectCycle;
if (cycle) {
cycle.maxBackoffMs = Math.max(cycle.maxBackoffMs, backoffMs);
}
}

#finishReconnect(outcome: ReconnectOutcome): void {
Expand All @@ -163,11 +173,12 @@ export class WebSocketTelemetry {
reason: cycle.reason,
};
if (outcome.result === "error") {
properties.terminationReason = outcome.terminationReason;
properties.termination_reason = outcome.terminationReason;
}
this.#telemetry.log("connection.reconnect_resolved", properties, {
attempts: cycle.attempts,
totalDurationMs: performance.now() - cycle.startMs,
max_backoff_ms: cycle.maxBackoffMs,
total_duration_ms: performance.now() - cycle.startMs,
});
}
}
Loading