Skip to content

Commit b10b32c

Browse files
authored
feat: add auth telemetry lifecycle traces (#994)
Add login/logout outcome traces with bounded source/method/reason, credential store/clear traces (keyring mode, category, failure category, duration), and deployment suspended/recovered/cross-window/auth-config-recovery-failed events. Command login owns the outer auth.login span so command, auto-login, and switch-deployment sources are captured once. Closes #984
1 parent 48645d1 commit b10b32c

16 files changed

Lines changed: 1020 additions & 130 deletions

src/commands.ts

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import * as cliExec from "./core/cliExec";
1414
import { CertificateError } from "./error/certificateError";
1515
import { toError } from "./error/errorUtils";
1616
import { type FeatureSet, featureSetForVersion } from "./featureSet";
17+
import {
18+
AuthTelemetry,
19+
type AuthLoginOutcome,
20+
type AuthLogoutOutcome,
21+
} from "./instrumentation/auth";
1722
import {
1823
reportElapsedProgress,
1924
withCancellableProgress,
@@ -49,7 +54,7 @@ import type { PathResolver } from "./core/pathResolver";
4954
import type { SecretsManager } from "./core/secretsManager";
5055
import type { DeploymentManager } from "./deployment/deploymentManager";
5156
import type { Logger } from "./logging/logger";
52-
import type { LoginCoordinator } from "./login/loginCoordinator";
57+
import type { LoginCoordinator, LoginMethod } from "./login/loginCoordinator";
5358
import type { TelemetryService } from "./telemetry/service";
5459
import type { SpeedtestPanelFactory } from "./webviews/speedtest/speedtestPanelFactory";
5560
import type {
@@ -68,6 +73,11 @@ interface OpenOptions {
6873
useDefaultDirectory?: boolean;
6974
}
7075

76+
interface LoginArgs {
77+
readonly url?: string;
78+
readonly autoLogin?: boolean;
79+
}
80+
7181
const openDefaults = {
7282
openRecent: false,
7383
useDefaultDirectory: true,
@@ -83,6 +93,7 @@ export class Commands {
8393
private readonly duplicateWorkspaceIpc: DuplicateWorkspaceIpc;
8494
private readonly speedtestPanelFactory: SpeedtestPanelFactory;
8595
private readonly telemetryService: TelemetryService;
96+
private readonly authTelemetry: AuthTelemetry;
8697

8798
// These will only be populated when actively connected to a workspace and are
8899
// used in commands. Because commands can be executed by the user, it is not
@@ -109,6 +120,7 @@ export class Commands {
109120
this.loginCoordinator = serviceContainer.getLoginCoordinator();
110121
this.duplicateWorkspaceIpc = serviceContainer.getDuplicateWorkspaceIpc();
111122
this.speedtestPanelFactory = serviceContainer.getSpeedtestPanelFactory();
123+
this.authTelemetry = new AuthTelemetry(this.telemetryService);
112124
}
113125

114126
/**
@@ -137,20 +149,20 @@ export class Commands {
137149
* Log into a deployment. If already authenticated, this is a no-op.
138150
* If no URL is provided, shows a menu of recent URLs plus defaults.
139151
*/
140-
public async login(args?: {
141-
url?: string;
142-
autoLogin?: boolean;
143-
}): Promise<void> {
152+
public async login(args?: LoginArgs): Promise<void> {
144153
if (this.deploymentManager.isAuthenticated()) {
145154
return;
146155
}
147-
await this.performLogin(args);
156+
await this.authTelemetry.traceLogin(
157+
args?.autoLogin ? "auto_login" : "command",
158+
(trace) => this.performLogin(args, trace.setMethod),
159+
);
148160
}
149161

150-
private async performLogin(args?: {
151-
url?: string;
152-
autoLogin?: boolean;
153-
}): Promise<void> {
162+
private async performLogin(
163+
args: LoginArgs | undefined,
164+
setMethod: (method: LoginMethod) => void,
165+
): Promise<AuthLoginOutcome> {
154166
this.logger.debug("Logging in");
155167

156168
const currentDeployment = await this.secretsManager.getCurrentDeployment();
@@ -160,7 +172,7 @@ export class Commands {
160172
currentDeployment?.url,
161173
);
162174
if (!url) {
163-
return; // The user aborted.
175+
return { success: false, reason: "no_url_provided" };
164176
}
165177

166178
const safeHostname = toSafeHost(url);
@@ -173,19 +185,26 @@ export class Commands {
173185
});
174186

175187
if (!result.success) {
176-
return;
188+
return result;
177189
}
178190

191+
// Record the method eagerly so it is captured even if persistence throws.
192+
setMethod(result.method);
179193
await this.deploymentManager.setDeployment({
180194
url,
181195
safeHostname,
182196
token: result.token,
183197
user: result.user,
184198
});
199+
this.showWelcomeMessage(result.user.username);
200+
this.logger.debug("Login complete to deployment:", url);
201+
return { success: true, method: result.method };
202+
}
185203

204+
private showWelcomeMessage(username: string): void {
186205
vscode.window
187206
.showInformationMessage(
188-
`Welcome to Coder, ${result.user.username}!`,
207+
`Welcome to Coder, ${username}!`,
189208
{
190209
detail:
191210
"You can now use the Coder extension to manage your Coder instance.",
@@ -197,7 +216,6 @@ export class Commands {
197216
vscode.commands.executeCommand("coder.open");
198217
}
199218
});
200-
this.logger.debug("Login complete to deployment:", url);
201219
}
202220

203221
/**
@@ -418,21 +436,30 @@ export class Commands {
418436
* Log out and clear stored credentials, requiring re-authentication on next login.
419437
*/
420438
public async logout(): Promise<void> {
439+
await this.authTelemetry.traceLogout(() => this.performLogout());
440+
}
441+
442+
private async performLogout(): Promise<AuthLogoutOutcome> {
421443
if (!this.deploymentManager.isAuthenticated()) {
422-
return;
444+
return { success: false, reason: "not_authenticated" };
423445
}
424446

425447
this.logger.debug("Logging out");
426448

427449
const deployment = this.deploymentManager.getCurrentDeployment();
428-
429-
await this.deploymentManager.clearDeployment();
450+
await this.deploymentManager.clearDeployment("logout");
430451

431452
if (deployment) {
432453
await this.cliManager.clearCredentials(deployment.url);
433454
await this.secretsManager.clearAllAuthData(deployment.safeHostname);
434455
}
435456

457+
this.showLogoutMessage();
458+
this.logger.debug("Logout complete");
459+
return { success: true };
460+
}
461+
462+
private showLogoutMessage(): void {
436463
vscode.window
437464
.showInformationMessage("You've been logged out of Coder!", "Login")
438465
.then((action) => {
@@ -442,8 +469,6 @@ export class Commands {
442469
});
443470
}
444471
});
445-
446-
this.logger.debug("Logout complete");
447472
}
448473

449474
/**
@@ -452,7 +477,9 @@ export class Commands {
452477
*/
453478
public async switchDeployment(): Promise<void> {
454479
this.logger.debug("Switching deployment");
455-
await this.performLogin();
480+
await this.authTelemetry.traceLogin("switch_deployment", (trace) =>
481+
this.performLogin(undefined, trace.setMethod),
482+
);
456483
}
457484

458485
/**

src/core/cliCredentialManager.ts

Lines changed: 70 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@ import * as semver from "semver";
77

88
import { isAbortError } from "../error/errorUtils";
99
import { featureSetForVersion } from "../featureSet";
10+
import {
11+
CredentialCliError,
12+
CredentialFileError,
13+
CredentialTelemetry,
14+
} from "../instrumentation/credentials";
1015
import { isKeyringEnabled } from "../settings/cli";
1116
import { getHeaderArgs } from "../settings/headers";
17+
import { type TelemetryReporter } from "../telemetry/reporter";
1218
import { toSafeHost } from "../util";
1319
import { writeAtomically } from "../util/fs";
1420

@@ -17,6 +23,7 @@ import { version } from "./cliExec";
1723
import type { WorkspaceConfiguration } from "vscode";
1824

1925
import type { Logger } from "../logging/logger";
26+
import type { Span } from "../telemetry/span";
2027

2128
import type { PathResolver } from "./pathResolver";
2229

@@ -46,11 +53,16 @@ export function isKeyringSupported(): boolean {
4653
* persistence: keyring-backed (via CLI) and file-based (plaintext).
4754
*/
4855
export class CliCredentialManager {
56+
private readonly credentialTelemetry: CredentialTelemetry;
57+
4958
constructor(
5059
private readonly logger: Logger,
5160
private readonly resolveBinary: BinaryResolver,
5261
private readonly pathResolver: PathResolver,
53-
) {}
62+
telemetry: TelemetryReporter,
63+
) {
64+
this.credentialTelemetry = new CredentialTelemetry(telemetry);
65+
}
5466

5567
/**
5668
* Store credentials for a deployment URL. Uses the OS keyring when the
@@ -59,22 +71,35 @@ export class CliCredentialManager {
5971
*
6072
* Keyring and files are mutually exclusive, never both.
6173
*/
62-
public async storeToken(
74+
public storeToken(
6375
url: string,
6476
token: string,
6577
configs: Pick<WorkspaceConfiguration, "get">,
6678
options?: { signal?: AbortSignal },
6779
): Promise<void> {
68-
const binPath = await this.resolveKeyringBinary(
69-
url,
70-
configs,
71-
"keyringAuth",
72-
);
73-
if (!binPath) {
74-
await this.writeCredentialFiles(url, token);
75-
return;
76-
}
80+
return this.credentialTelemetry.traceStore(configs, async (span) => {
81+
const binPath = await this.resolveKeyringBinary(
82+
url,
83+
configs,
84+
"keyringAuth",
85+
);
86+
if (!binPath) {
87+
span.setProperty("category", "file");
88+
await this.writeCredentialFiles(url, token);
89+
return;
90+
}
91+
span.setProperty("category", "keyring");
92+
await this.storeKeyringToken(binPath, url, token, configs, options);
93+
});
94+
}
7795

96+
private async storeKeyringToken(
97+
binPath: string,
98+
url: string,
99+
token: string,
100+
configs: Pick<WorkspaceConfiguration, "get">,
101+
options?: { signal?: AbortSignal },
102+
): Promise<void> {
78103
const args = [
79104
...getHeaderArgs(configs),
80105
"login",
@@ -89,7 +114,10 @@ export class CliCredentialManager {
89114
this.logger.info("Stored token via CLI for", url);
90115
} catch (error) {
91116
this.logger.warn("Failed to store token via CLI:", error);
92-
throw error;
117+
if (isAbortError(error)) {
118+
throw error;
119+
}
120+
throw new CredentialCliError(error);
93121
}
94122
}
95123

@@ -139,15 +167,20 @@ export class CliCredentialManager {
139167
* deletion in parallel, both best-effort. Throws AbortError when the
140168
* signal is aborted.
141169
*/
142-
public async deleteToken(
170+
public deleteToken(
143171
url: string,
144172
configs: Pick<WorkspaceConfiguration, "get">,
145173
options?: { signal?: AbortSignal },
146174
): Promise<void> {
147-
await Promise.all([
148-
this.deleteCredentialFiles(url),
149-
this.deleteKeyringToken(url, configs, options?.signal),
150-
]);
175+
return this.credentialTelemetry.traceClear(configs, async (span) => {
176+
await Promise.all([
177+
this.deleteCredentialFiles(url),
178+
this.deleteKeyringToken(url, configs, {
179+
signal: options?.signal,
180+
span,
181+
}),
182+
]);
183+
});
151184
}
152185

153186
/**
@@ -201,14 +234,18 @@ export class CliCredentialManager {
201234
url: string,
202235
token: string,
203236
): Promise<void> {
204-
const safeHostname = toSafeHost(url);
205-
await Promise.all([
206-
this.atomicWriteFile(this.pathResolver.getUrlPath(safeHostname), url),
207-
this.atomicWriteFile(
208-
this.pathResolver.getSessionTokenPath(safeHostname),
209-
token,
210-
),
211-
]);
237+
try {
238+
const safeHostname = toSafeHost(url);
239+
await Promise.all([
240+
this.atomicWriteFile(this.pathResolver.getUrlPath(safeHostname), url),
241+
this.atomicWriteFile(
242+
this.pathResolver.getSessionTokenPath(safeHostname),
243+
token,
244+
),
245+
]);
246+
} catch (error) {
247+
throw new CredentialFileError(error);
248+
}
212249
}
213250

214251
/**
@@ -230,18 +267,22 @@ export class CliCredentialManager {
230267
}
231268

232269
/**
233-
* Delete keyring token via `coder logout`. Best-effort: never throws.
270+
* Delete keyring token via `coder logout`. Best-effort: records the failure
271+
* on the span instead of throwing (except on abort), so it is tagged where
272+
* it occurs.
234273
*/
235274
private async deleteKeyringToken(
236275
url: string,
237276
configs: Pick<WorkspaceConfiguration, "get">,
238-
signal?: AbortSignal,
277+
{ signal, span }: { signal?: AbortSignal; span: Span },
239278
): Promise<void> {
240279
let binPath: string | undefined;
241280
try {
242281
binPath = await this.resolveKeyringBinary(url, configs, "keyringAuth");
243282
} catch (error) {
244283
this.logger.warn("Could not resolve keyring binary for delete:", error);
284+
span.setProperty("failure_category", "binary");
285+
span.markFailure();
245286
return;
246287
}
247288
if (!binPath) {
@@ -257,6 +298,8 @@ export class CliCredentialManager {
257298
throw error;
258299
}
259300
this.logger.warn("Failed to delete token via CLI:", error);
301+
span.setProperty("failure_category", "cli");
302+
span.markFailure();
260303
}
261304
}
262305

src/core/container.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export class ServiceContainer implements vscode.Disposable {
9090
}
9191
},
9292
this.pathResolver,
93+
this.telemetryService,
9394
);
9495
this.cliManager = new CliManager(
9596
this.logger,

0 commit comments

Comments
 (0)