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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
local telemetry.
- Local telemetry now records `http.requests` rollups for per-route HTTP
health without emitting one event per request.
- Local telemetry now records authentication refresh/recovery attempts for
connection debugging.
- Local telemetry now records workspace and agent state transitions with observed
durations for connection lifecycle diagnostics.

### Fixed

Expand Down
17 changes: 17 additions & 0 deletions src/api/authInterceptor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { type AxiosError, isAxiosError } from "axios";

import {
AuthTelemetry,
type AuthIntercept401Recovery,
} from "../instrumentation/auth";
import { OAuthError } from "../oauth/errors";
import {
NOOP_TELEMETRY_REPORTER,
type TelemetryReporter,
} from "../telemetry/reporter";
import { toSafeHost } from "../util";

import type * as vscode from "vscode";
Expand Down Expand Up @@ -28,6 +36,7 @@ export type AuthRequiredHandler = (hostname: string) => Promise<boolean>;
*/
export class AuthInterceptor implements vscode.Disposable {
private readonly interceptorId: number;
private readonly authTelemetry: AuthTelemetry;
private authRequiredPromise: Promise<boolean> | null = null;

constructor(
Expand All @@ -36,7 +45,9 @@ export class AuthInterceptor implements vscode.Disposable {
private readonly oauthSessionManager: OAuthSessionManager,
private readonly secretsManager: SecretsManager,
private readonly onAuthRequired?: AuthRequiredHandler,
telemetry: TelemetryReporter = NOOP_TELEMETRY_REPORTER,
) {
this.authTelemetry = new AuthTelemetry(telemetry);
this.interceptorId = this.client
.getAxiosInstance()
.interceptors.response.use(
Expand Down Expand Up @@ -77,11 +88,15 @@ export class AuthInterceptor implements vscode.Disposable {
): Promise<unknown> {
this.logger.debug("Received 401 response, attempting recovery");

let recovery: AuthIntercept401Recovery = "none";

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");
recovery = "refresh_success";
this.authTelemetry.intercept401(recovery);
return this.retryRequest(error, newTokens.access_token);
} catch (refreshError) {
if (refreshError instanceof OAuthError) {
Expand All @@ -98,6 +113,7 @@ export class AuthInterceptor implements vscode.Disposable {
}

if (this.onAuthRequired) {
recovery = "login_required";
const success = await this.executeAuthRequired(hostname);
if (success) {
const auth = await this.secretsManager.getSessionAuth(hostname);
Expand All @@ -108,6 +124,7 @@ export class AuthInterceptor implements vscode.Disposable {
}
}

this.authTelemetry.intercept401(recovery);
throw error;
}

Expand Down
1 change: 1 addition & 0 deletions src/core/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export class ServiceContainer implements vscode.Disposable {
this.cliCredentialManager,
this.oauthCallback,
context.extension.id,
this.telemetryService,
);
this.duplicateWorkspaceIpc = new DuplicateWorkspaceIpc(
context.secrets,
Expand Down
2 changes: 2 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ async function doActivate(
deployment,
serviceContainer,
handleAuthFailure,
telemetryService,
);
ctx.subscriptions.push(oauthSessionManager);

Expand All @@ -152,6 +153,7 @@ async function doActivate(
await handleAuthFailure();
return false;
},
telemetryService,
);
ctx.subscriptions.push(authInterceptor);

Expand Down
44 changes: 44 additions & 0 deletions src/instrumentation/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
NOOP_TELEMETRY_REPORTER,
type TelemetryReporter,
} from "../telemetry/reporter";

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

/** Helpers scoped to the auth.login_prompt trace's lifetime. */
export interface LoginPromptTracer {
markAborted(): void;
}

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

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

public intercept401(recovery: AuthIntercept401Recovery): void {
this.telemetry.log("auth.intercept_401", { recovery });
}

public traceLoginPrompt<T>(
trigger: AuthLoginPromptTrigger,
fn: (tracer: LoginPromptTracer) => Promise<T>,
): Promise<T> {
return this.telemetry.trace(
"auth.login_prompt",
(span) => fn({ markAborted: () => span.markAborted() }),
{ trigger },
);
}
}
95 changes: 95 additions & 0 deletions src/instrumentation/workspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
NOOP_TELEMETRY_REPORTER,
type TelemetryReporter,
} from "../telemetry/reporter";

import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentLifecycle,
WorkspaceAgentStatus,
WorkspaceStatus,
} from "coder/site/src/api/typesGenerated";

const INITIAL_STATE = "unknown";

interface ObservedWorkspaceState {
readonly status: WorkspaceStatus;
readonly observedAtMs: number;
}

interface ObservedAgentState {
readonly status: WorkspaceAgentStatus;
readonly lifecycleState: WorkspaceAgentLifecycle;
readonly observedAtMs: number;
}

export class WorkspaceTelemetry {
private observedWorkspaceState: ObservedWorkspaceState | undefined;
private observedAgentState: ObservedAgentState | undefined;

public constructor(
private readonly telemetry: TelemetryReporter = NOOP_TELEMETRY_REPORTER,
) {}

public observeWorkspace(workspace: Workspace): void {
const status = workspace.latest_build.status;
const previous = this.observedWorkspaceState;
if (previous?.status === status) {
return;
}
const now = performance.now();

this.telemetry.log(
"workspace.state_transitioned",
{
from: previous?.status ?? INITIAL_STATE,
to: status,
...(workspace.latest_build.transition && {
transition: workspace.latest_build.transition,
}),
...(workspace.latest_build.reason && {
reason: workspace.latest_build.reason,
}),
},
previous ? { observedDurationMs: now - previous.observedAtMs } : {},
);
this.observedWorkspaceState = { status, observedAtMs: now };
}

public observeAgent(agent: WorkspaceAgent): void {
const previous = this.observedAgentState;
if (
previous?.status === agent.status &&
previous.lifecycleState === agent.lifecycle_state
) {
return;
}
const now = performance.now();

this.telemetry.log(
"workspace.agent.state_transitioned",
{
agentName: agent.name,
fromStatus: previous?.status ?? INITIAL_STATE,
toStatus: agent.status,
fromLifecycleState: previous?.lifecycleState ?? INITIAL_STATE,
toLifecycleState: agent.lifecycle_state,
},
previous ? { observedDurationMs: now - previous.observedAtMs } : {},
);
this.observedAgentState = {
status: agent.status,
lifecycleState: agent.lifecycle_state,
observedAtMs: now,
};
}

public resetAgent(): void {
this.observedAgentState = undefined;
}

public traceUpdateTriggered<T>(fn: () => Promise<T>): Promise<T> {
return this.telemetry.trace("workspace.update.triggered", fn);
}
}
30 changes: 30 additions & 0 deletions src/login/loginCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,19 @@ import * as vscode from "vscode";
import { CoderApi } from "../api/coderApi";
import { needToken } from "../api/utils";
import { CertificateError } from "../error/certificateError";
import {
AuthTelemetry,
type AuthLoginPromptTrigger,
} from "../instrumentation/auth";
import { OAuthAuthorizer } from "../oauth/authorizer";
import { buildOAuthTokenData } from "../oauth/utils";
import { withOptionalProgress } from "../progress";
import { maybeAskAuthMethod, maybeAskUrl } from "../promptUtils";
import { isKeyringEnabled } from "../settings/cli";
import {
NOOP_TELEMETRY_REPORTER,
type TelemetryReporter,
} from "../telemetry/reporter";
import { vscodeProposed } from "../vscodeProposed";

import type { User } from "coder/site/src/api/typesGenerated";
Expand Down Expand Up @@ -38,6 +46,7 @@ export interface LoginOptions {
export class LoginCoordinator implements vscode.Disposable {
private loginQueue: Promise<unknown> = Promise.resolve();
private readonly oauthAuthorizer: OAuthAuthorizer;
private readonly authTelemetry: AuthTelemetry;

constructor(
private readonly secretsManager: SecretsManager,
Expand All @@ -46,7 +55,9 @@ export class LoginCoordinator implements vscode.Disposable {
private readonly cliCredentialManager: CliCredentialManager,
oauthCallback: OAuthCallback,
extensionId: string,
telemetry: TelemetryReporter = NOOP_TELEMETRY_REPORTER,
) {
this.authTelemetry = new AuthTelemetry(telemetry);
this.oauthAuthorizer = new OAuthAuthorizer(
secretsManager,
oauthCallback,
Expand Down Expand Up @@ -80,6 +91,25 @@ export class LoginCoordinator implements vscode.Disposable {
* Shows dialog then login - for system-initiated auth (remote, OAuth refresh).
*/
public async ensureLoggedInWithDialog(
options: LoginOptions & {
message?: string;
detailPrefix?: string;
trigger?: AuthLoginPromptTrigger;
},
): Promise<LoginResult> {
return this.authTelemetry.traceLoginPrompt(
options.trigger ?? "auth_required",
async (tracer) => {
const result = await this.performLoginDialog(options);
if (!result.success) {
tracer.markAborted();
}
return result;
},
);
}

private async performLoginDialog(
options: LoginOptions & { message?: string; detailPrefix?: string },
): Promise<LoginResult> {
const { safeHostname, url, detailPrefix, message } = options;
Expand Down
27 changes: 23 additions & 4 deletions src/oauth/sessionManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { CoderApi } from "../api/coderApi";
import {
AuthTelemetry,
type AuthTokenRefreshTrigger,
} from "../instrumentation/auth";
import {
NOOP_TELEMETRY_REPORTER,
type TelemetryReporter,
} from "../telemetry/reporter";

import { DEFAULT_OAUTH_SCOPES, REFRESH_GRANT_TYPE } from "./constants";
import { OAuthError, parseOAuthError } from "./errors";
Expand Down Expand Up @@ -58,24 +66,31 @@ export class OAuthSessionManager implements vscode.Disposable {
deployment: Deployment | null,
container: ServiceContainer,
onAuthRequired: () => Promise<void> = () => Promise.resolve(),
telemetry: TelemetryReporter = NOOP_TELEMETRY_REPORTER,
): OAuthSessionManager {
const manager = new OAuthSessionManager(
deployment,
container.getSecretsManager(),
container.getLogger(),
onAuthRequired,
telemetry,
);
manager.setupTokenListener();
manager.scheduleNextRefresh();
return manager;
}

private readonly authTelemetry: AuthTelemetry;

private constructor(
private deployment: Deployment | null,
private readonly secretsManager: SecretsManager,
private readonly logger: Logger,
private readonly onAuthRequired: () => Promise<void>,
) {}
telemetry: TelemetryReporter,
) {
this.authTelemetry = new AuthTelemetry(telemetry);
}

/**
* Get current deployment, throwing if not set.
Expand Down Expand Up @@ -218,7 +233,7 @@ export class OAuthSessionManager implements vscode.Disposable {

this.refreshTimer = undefined;

this.refreshToken()
this.refreshToken("background")
.then(() => {
this.logger.debug("Background token refresh succeeded");
})
Expand Down Expand Up @@ -342,7 +357,9 @@ export class OAuthSessionManager implements vscode.Disposable {
* Refresh the access token using the stored refresh token.
* Uses a shared promise to handle concurrent refresh attempts.
*/
public async refreshToken(): Promise<OAuth2TokenResponse> {
public async refreshToken(
trigger: AuthTokenRefreshTrigger = "reactive",
): Promise<OAuth2TokenResponse> {
if (this.refreshPromise) {
this.logger.debug(
"Token refresh already in progress, waiting for result",
Expand All @@ -352,7 +369,9 @@ export class OAuthSessionManager implements vscode.Disposable {

const deployment = this.requireDeployment();
// Assign synchronously before any async work to prevent race conditions
this.refreshPromise = this.executeTokenRefresh(deployment);
this.refreshPromise = this.authTelemetry.traceTokenRefresh(trigger, () =>
this.executeTokenRefresh(deployment),
);
return this.refreshPromise;
}

Expand Down
Loading
Loading