Skip to content
Merged
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
22 changes: 10 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,18 @@
machine only and never sent anywhere. Configure via the new
`coder.telemetry.level` setting (`local` by default, `off` to disable);
see `coder.telemetry.local` for tunables.
- Every `coder.*` command now records a `command.invoked` telemetry event with
its duration and outcome, so command latency and failures are captured
alongside other local telemetry.
- Extension activation, remote workspace setup phases (auth retrieval,
workspace lookup, workspace and agent readiness, SSH config write), and CLI
binary download/verify now emit local telemetry events with their duration
and outcome, so startup latency and failures are captured alongside other
local telemetry.
- Local telemetry now records `command.invoked` for each `coder.*` command
with duration and outcome.
- Local telemetry now records extension activation, remote workspace setup
phases (auth retrieval, workspace lookup, workspace and agent readiness,
SSH config write), and CLI binary download/verify with their durations
and outcomes.
- Local telemetry now records `http.requests` rollups for per-route HTTP
health without emitting one event per request.
- Connection lifecycle now records local telemetry: SSH process
health, without emitting one event per request.
- Local telemetry now records connection lifecycle: SSH process
discovery/loss/recovery with sampled network info, and reconnecting
WebSocket open, drop, reconnect, and state transitions, so connection
stability is captured alongside other local telemetry.
WebSocket open/drop/reconnect/state transitions.
- Local telemetry now records authentication refresh and recovery prompts.

### Fixed

Expand Down
85 changes: 54 additions & 31 deletions src/api/authInterceptor.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { type AxiosError, isAxiosError } from "axios";

import { AuthTelemetry } from "../instrumentation/auth";
import { OAuthError } from "../oauth/errors";
import { toSafeHost } from "../util";

import type * as vscode from "vscode";

import type { ServiceContainer } from "../core/container";
import type { SecretsManager } from "../core/secretsManager";
import type { Logger } from "../logging/logger";
import type { RequestConfigWithMeta } from "../logging/types";
Expand All @@ -28,15 +30,20 @@ export type AuthRequiredHandler = (hostname: string) => Promise<boolean>;
*/
export class AuthInterceptor implements vscode.Disposable {
private readonly interceptorId: number;
private readonly authTelemetry: AuthTelemetry;
private readonly logger: Logger;
private readonly secretsManager: SecretsManager;
private authRequiredPromise: Promise<boolean> | null = null;

constructor(
private readonly client: CoderApi,
private readonly logger: Logger,
private readonly oauthSessionManager: OAuthSessionManager,
private readonly secretsManager: SecretsManager,
container: ServiceContainer,
private readonly onAuthRequired?: AuthRequiredHandler,
) {
this.logger = container.getLogger();
this.secretsManager = container.getSecretsManager();
this.authTelemetry = new AuthTelemetry(container.getTelemetryService());
this.interceptorId = this.client
.getAxiosInstance()
.interceptors.response.use(
Expand Down Expand Up @@ -68,47 +75,63 @@ export class AuthInterceptor implements vscode.Disposable {
}
const hostname = toSafeHost(baseUrl);

return this.handle401Error(error, hostname);
return this.recoverFromUnauthorized(error, hostname);
}

private async handle401Error(
private recoverFromUnauthorized(
error: AxiosError,
hostname: string,
): Promise<unknown> {
this.logger.debug("Received 401 response, attempting recovery");

if (await this.oauthSessionManager.isLoggedInWithOAuth(hostname)) {
try {
const newTokens = await this.oauthSessionManager.refreshToken();
this.client.setSessionToken(newTokens.access_token);
this.logger.debug("Token refresh successful, retrying request");
return this.retryRequest(error, newTokens.access_token);
} catch (refreshError) {
if (refreshError instanceof OAuthError) {
const msg = `Token refresh failed: ${refreshError.message}`;
if (refreshError.requiresReAuth) {
this.logger.warn(msg);
} else {
this.logger.error(msg);
}
} else {
this.logger.error("Token refresh failed:", refreshError);
// TODO(#925): emit a correlated received-log here once Span.log() lands.
return this.authTelemetry.traceAuthRecovery(async (recorder) => {
// 1) OAuth refresh path.
const isOAuth =
await this.oauthSessionManager.isLoggedInWithOAuth(hostname);
recorder.setRefreshAttempted(isOAuth);
if (isOAuth) {
const newToken = await this.tryOAuthRefresh();
if (newToken) {
recorder.setRecovery("refresh_success");
return this.retryRequest(error, newToken);
}
}
}

if (this.onAuthRequired) {
// 2) Interactive re-auth fallback.
if (!this.onAuthRequired) {
recorder.setRecovery("none");
throw error;
}
recorder.setRecovery("login_required");
const success = await this.executeAuthRequired(hostname);
if (success) {
const auth = await this.secretsManager.getSessionAuth(hostname);
if (auth) {
this.logger.debug("Re-authentication successful, retrying request");
return this.retryRequest(error, auth.token);
}
const auth = success
? await this.secretsManager.getSessionAuth(hostname)
: undefined;
if (!auth) {
throw error;
}
}
this.logger.debug("Re-authentication successful, retrying request");
return this.retryRequest(error, auth.token);
});
Comment thread
DanielleMaywood marked this conversation as resolved.
}

throw error;
/** Returns the new access token on success, or undefined when refresh fails. */
private async tryOAuthRefresh(): Promise<string | undefined> {
try {
const newTokens = await this.oauthSessionManager.refreshToken();
this.client.setSessionToken(newTokens.access_token);
this.logger.debug("Token refresh successful");
return newTokens.access_token;
} catch (refreshError) {
if (!(refreshError instanceof OAuthError)) {
this.logger.error("Token refresh failed:", refreshError);
} else if (refreshError.requiresReAuth) {
this.logger.warn(`Token refresh failed: ${refreshError.message}`);
} else {
this.logger.error(`Token refresh failed: ${refreshError.message}`);
}
return undefined;
}
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/core/container.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as vscode from "vscode";

import { CoderApi } from "../api/coderApi";
import { AuthTelemetry } from "../instrumentation/auth";
import { LoginCoordinator } from "../login/loginCoordinator";
import { OAuthCallback } from "../oauth/oauthCallback";
import { buildSession, extractExtensionVersion } from "../telemetry/event";
Expand Down Expand Up @@ -103,6 +104,7 @@ export class ServiceContainer implements vscode.Disposable {
this.mementoManager,
this.logger,
this.cliCredentialManager,
new AuthTelemetry(this.telemetryService),
this.oauthCallback,
context.extension.id,
);
Expand Down
3 changes: 1 addition & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,8 @@ async function doActivate(
// Handles 401 responses (OAuth and otherwise)
const authInterceptor = new AuthInterceptor(
client,
output,
oauthSessionManager,
secretsManager,
serviceContainer,
async () => {
await handleAuthFailure();
return false;
Expand Down
81 changes: 81 additions & 0 deletions src/instrumentation/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { TelemetryReporter } from "../telemetry/reporter";

export type AuthTokenRefreshTrigger = "background" | "reactive";
export type AuthRecoveryAction = "refresh_success" | "login_required" | "none";
export type AuthLoginPromptTrigger = "auth_required" | "missing_session";

export type LoginPromptReason =
| "user_dismissed"
| "no_url_provided"
| "auth_failed";

export type LoginPromptOutcome =
| { success: true }
| { success: false; reason: LoginPromptReason };

interface AuthRecoveryRecorder {
setRecovery(recovery: AuthRecoveryAction): void;
setRefreshAttempted(attempted: boolean): void;
}

export class AuthTelemetry {
public constructor(private readonly telemetry: TelemetryReporter) {}

public traceTokenRefresh<T>(
trigger: AuthTokenRefreshTrigger,
fn: () => Promise<T>,
): Promise<T> {
return this.telemetry.trace("auth.token_refreshed", fn, { trigger });
}

/** Logged when a refresh call joins an in-flight refresh and emits no span of its own. */
public logTokenRefreshDeduped(trigger: AuthTokenRefreshTrigger): void {
this.telemetry.log("auth.token_refresh.deduped", { trigger });
}
Comment thread
EhabY marked this conversation as resolved.

/**
* Wraps the auth-recovery path triggered by a 401. Initial properties
* cover the throw-before-callback case.
*/
public traceAuthRecovery<T>(
fn: (recorder: AuthRecoveryRecorder) => Promise<T>,
): Promise<T> {
return this.telemetry.trace(
"auth.unauthorized_intercepted",
(span) =>
fn({
setRecovery: (recovery) => span.setProperty("recovery", recovery),
setRefreshAttempted: (attempted) =>
span.setProperty("refreshAttempted", attempted),
}),
{ recovery: "none", refreshAttempted: false },
);
}

/**
* Records `auth.login_prompted`. `auth_failed` marks the span as failure;
* other non-success reasons mark it as aborted. The reason is copied to the
* span's `reason` property on failure/abort only.
*/
public traceLoginPrompt<T extends LoginPromptOutcome>(
trigger: AuthLoginPromptTrigger,
fn: () => Promise<T>,
): Promise<T> {
return this.telemetry.trace(
"auth.login_prompted",
async (span) => {
const result = await fn();
if (!result.success) {
span.setProperty("reason", result.reason);
if (result.reason === "auth_failed") {
span.markFailure();
} else {
span.markAborted();
}
}
return result;
},
{ trigger },
);
}
}
10 changes: 4 additions & 6 deletions src/instrumentation/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export class SshTelemetry {
): Promise<number | undefined> {
return this.#telemetry.trace("ssh.process.discovered", async (span) => {
const { pid, attempts } = await fn();
span.setProperty("found", String(pid !== undefined));
span.setProperty("found", pid !== undefined);
span.setMeasurement("attempts", attempts);
return pid;
});
Expand Down Expand Up @@ -79,7 +79,6 @@ export class SshTelemetry {
public processReplaced(): void {
const now = performance.now();
if (this.#processStartedAtMs !== undefined) {
const wasLost = this.#processLostAtMs !== undefined;
const measurements: Record<string, number> = {
previousUptimeMs: now - this.#processStartedAtMs,
};
Expand All @@ -88,7 +87,7 @@ export class SshTelemetry {
}
this.#telemetry.log(
"ssh.process.replaced",
{ wasLost: String(wasLost) },
{ wasLost: this.#processLostAtMs !== undefined },
measurements,
);
}
Expand All @@ -104,10 +103,9 @@ export class SshTelemetry {
return;
}
const now = performance.now();
const wasLost = this.#processLostAtMs !== undefined;
this.#telemetry.log(
"ssh.process.disposed",
{ wasLost: String(wasLost) },
{ wasLost: this.#processLostAtMs !== undefined },
{ uptimeMs: now - this.#processStartedAtMs },
);
this.#processStartedAtMs = undefined;
Expand All @@ -131,7 +129,7 @@ export class SshTelemetry {
this.#telemetry.log(
"ssh.network.sampled",
{
p2p: String(network.p2p),
p2p: network.p2p,
preferredDerp: network.preferred_derp,
},
{
Expand Down
16 changes: 6 additions & 10 deletions src/instrumentation/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ export class WebSocketTelemetry {
readonly #telemetry: TelemetryReporter;
#connectStartedAtMs: number | undefined;
#connectionOpenedAtMs: number | undefined;
#connectionDropEmitted = false;
#reconnectCycle: ReconnectCycle | undefined;

public constructor(telemetry: TelemetryReporter) {
Expand Down Expand Up @@ -80,7 +79,6 @@ export class WebSocketTelemetry {
const now = performance.now();
const start = this.#connectStartedAtMs ?? now;
this.#connectionOpenedAtMs = now;
this.#connectionDropEmitted = false;
this.#connectStartedAtMs = undefined;
this.#telemetry.log(
"connection.opened",
Expand All @@ -95,19 +93,19 @@ export class WebSocketTelemetry {
closeCode?: number,
error?: unknown,
): void {
if (
this.#connectionOpenedAtMs === undefined ||
this.#connectionDropEmitted
) {
// Capture-and-clear up-front so a throw, future await, or re-entry can't re-emit.
const openedAtMs = this.#connectionOpenedAtMs;
if (openedAtMs === undefined) {
return;
}
this.#connectionOpenedAtMs = undefined;

const properties: CallerProperties = { cause };
if (closeCode !== undefined) {
properties.closeCode = String(closeCode);
properties.closeCode = closeCode;
}
const measurements = {
connectionDurationMs: performance.now() - this.#connectionOpenedAtMs,
connectionDurationMs: performance.now() - openedAtMs,
};
if (error === undefined) {
this.#telemetry.log("connection.dropped", properties, measurements);
Expand All @@ -119,13 +117,11 @@ export class WebSocketTelemetry {
measurements,
);
}
this.#connectionDropEmitted = true;
}

public reset(): void {
this.#connectStartedAtMs = undefined;
this.#connectionOpenedAtMs = undefined;
this.#connectionDropEmitted = false;
this.#reconnectCycle = undefined;
}

Expand Down
Loading
Loading