Skip to content

Commit 27bdb91

Browse files
committed
feat: instrument startup telemetry
1 parent 106516c commit 27bdb91

12 files changed

Lines changed: 700 additions & 264 deletions

File tree

src/core/cliManager.ts

Lines changed: 70 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type { Api } from "coder/site/src/api/api";
2525
import type { IncomingMessage } from "node:http";
2626

2727
import type { Logger } from "../logging/logger";
28+
import type { TelemetryService } from "../telemetry/service";
2829

2930
import type { CliCredentialManager } from "./cliCredentialManager";
3031
import type { PathResolver } from "./pathResolver";
@@ -33,13 +34,26 @@ type ResolvedBinary =
3334
| { binPath: string; stat: Stats; source: "file-path" | "directory" }
3435
| { binPath: string; source: "not-found" };
3536

37+
type CliDownloadReason = "missing" | "version_mismatch";
38+
39+
interface BinaryDownloadResult {
40+
binPath: string;
41+
downloadedBytes?: number;
42+
}
43+
44+
interface DownloadResult {
45+
status: number;
46+
downloadedBytes: number;
47+
}
48+
3649
export class CliManager {
3750
private readonly binaryLock: BinaryLock;
3851

3952
constructor(
4053
private readonly output: Logger,
4154
private readonly pathResolver: PathResolver,
4255
private readonly cliCredentialManager: CliCredentialManager,
56+
private readonly telemetry: TelemetryService,
4357
) {
4458
this.binaryLock = new BinaryLock(output);
4559
}
@@ -140,6 +154,8 @@ export class CliManager {
140154
);
141155

142156
let existingVersion: string | null = null;
157+
const downloadReason: CliDownloadReason =
158+
resolved.source === "not-found" ? "missing" : "version_mismatch";
143159
if (resolved.source !== "not-found") {
144160
this.output.debug(
145161
"Existing binary size is",
@@ -224,13 +240,28 @@ export class CliManager {
224240
latestVersion = latestParsedVersion;
225241
}
226242

227-
await this.performBinaryDownload(
228-
restClient,
229-
latestVersion,
230-
downloadBinPath,
231-
progressLogPath,
243+
return await this.telemetry.trace(
244+
"cli.download",
245+
async (span) => {
246+
const recordDownloadedBytes = (downloadedBytes: number): void => {
247+
if (downloadedBytes > 0) {
248+
span.setMeasurement("downloadedBytes", downloadedBytes);
249+
}
250+
};
251+
const downloadResult = await this.performBinaryDownload(
252+
restClient,
253+
latestVersion,
254+
downloadBinPath,
255+
progressLogPath,
256+
recordDownloadedBytes,
257+
);
258+
if (downloadResult.downloadedBytes !== undefined) {
259+
recordDownloadedBytes(downloadResult.downloadedBytes);
260+
}
261+
return this.renameToFinalPath(resolved, downloadResult.binPath);
262+
},
263+
{ reason: downloadReason },
232264
);
233-
return await this.renameToFinalPath(resolved, downloadBinPath);
234265
} catch (error) {
235266
const fallback = await this.handleAnyBinaryFailure(
236267
error,
@@ -413,7 +444,8 @@ export class CliManager {
413444
parsedVersion: semver.SemVer,
414445
binPath: string,
415446
progressLogPath: string,
416-
): Promise<string> {
447+
recordDownloadedBytes: (downloadedBytes: number) => void,
448+
): Promise<BinaryDownloadResult> {
417449
const cfg = vscode.workspace.getConfiguration("coder");
418450
const tempFile = tempFilePath(binPath, "temp");
419451

@@ -449,6 +481,7 @@ export class CliManager {
449481
bytesDownloaded: number,
450482
totalBytes: number | null,
451483
) => {
484+
recordDownloadedBytes(bytesDownloaded);
452485
await downloadProgress.writeProgress(progressLogPath, {
453486
bytesDownloaded,
454487
totalBytes,
@@ -457,7 +490,7 @@ export class CliManager {
457490
};
458491

459492
const client = restClient.getAxiosInstance();
460-
const status = await this.download(
493+
const downloadResult = await this.download(
461494
client,
462495
binSource,
463496
writeStream,
@@ -467,7 +500,7 @@ export class CliManager {
467500
onProgress,
468501
);
469502

470-
switch (status) {
503+
switch (downloadResult.status) {
471504
case 200: {
472505
await downloadProgress.writeProgress(progressLogPath, {
473506
bytesDownloaded: 0,
@@ -480,27 +513,32 @@ export class CliManager {
480513
"Skipping binary signature verification due to settings",
481514
);
482515
} else {
483-
await this.verifyBinarySignatures(client, tempFile, [
484-
// A signature placed at the same level as the binary. It must be
485-
// named exactly the same with an appended `.asc` (such as
486-
// coder-windows-amd64.exe.asc or coder-linux-amd64.asc).
487-
binSource + ".asc",
488-
// The releases.coder.com bucket does not include the leading "v",
489-
// and unlike what we get from buildinfo it uses a truncated version
490-
// with only major.minor.patch. The signature name follows the same
491-
// rule as above.
492-
`https://releases.coder.com/coder-cli/${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}/${binName}.asc`,
493-
]);
516+
await this.telemetry.trace("cli.verify", () =>
517+
this.verifyBinarySignatures(client, tempFile, [
518+
// A signature placed at the same level as the binary. It must be
519+
// named exactly the same with an appended `.asc` (such as
520+
// coder-windows-amd64.exe.asc or coder-linux-amd64.asc).
521+
binSource + ".asc",
522+
// The releases.coder.com bucket does not include the leading "v",
523+
// and unlike what we get from buildinfo it uses a truncated version
524+
// with only major.minor.patch. The signature name follows the same
525+
// rule as above.
526+
`https://releases.coder.com/coder-cli/${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}/${binName}.asc`,
527+
]),
528+
);
494529
}
495530

496531
// Replace existing binary (handles both renames + Windows lock)
497532
await this.replaceExistingBinary(binPath, tempFile);
498533

499-
return binPath;
534+
return {
535+
binPath,
536+
downloadedBytes: downloadResult.downloadedBytes,
537+
};
500538
}
501539
case 304: {
502540
this.output.info("Using existing binary since server returned a 304");
503-
return binPath;
541+
return { binPath };
504542
}
505543
case 404: {
506544
vscode.window
@@ -537,7 +575,7 @@ export class CliManager {
537575
}
538576
const params = new URLSearchParams({
539577
title: `Failed to download binary on \`${cliUtils.goos()}-${cliUtils.goarch()}\``,
540-
body: `Received status code \`${status}\` when downloading the binary.`,
578+
body: `Received status code \`${downloadResult.status}\` when downloading the binary.`,
541579
});
542580
const uri = vscode.Uri.parse(
543581
`https://github.com/coder/vscode-coder/issues/new?${params.toString()}`,
@@ -565,7 +603,7 @@ export class CliManager {
565603
bytesDownloaded: number,
566604
totalBytes: number | null,
567605
) => Promise<void>,
568-
): Promise<number> {
606+
): Promise<DownloadResult> {
569607
const baseUrl = client.defaults.baseURL;
570608

571609
const controller = new AbortController();
@@ -583,6 +621,7 @@ export class CliManager {
583621
});
584622
this.output.info("Got status code", resp.status);
585623

624+
let written = 0;
586625
if (resp.status === 200) {
587626
const rawContentLength = (resp.headers["content-length"] ??
588627
resp.headers["x-original-content-length"]) as unknown;
@@ -599,9 +638,6 @@ export class CliManager {
599638
this.output.info("Got content length", prettyBytes(contentLength));
600639
}
601640

602-
// Track how many bytes were written.
603-
let written = 0;
604-
605641
const completed = await vscode.window.withProgress<boolean>(
606642
{
607643
location: vscode.ProgressLocation.Notification,
@@ -686,7 +722,10 @@ export class CliManager {
686722
this.output.info(`Downloaded ${prettyBytes(written)}`);
687723
}
688724

689-
return resp.status;
725+
return {
726+
status: resp.status,
727+
downloadedBytes: written,
728+
};
690729
}
691730

692731
/**
@@ -776,8 +815,8 @@ export class CliManager {
776815
this.output.info("Downloading signature from", source);
777816
const signaturePath = path.join(cliPath + ".asc");
778817
const writeStream = createWriteStream(signaturePath);
779-
const status = await this.download(client, source, writeStream);
780-
if (status === 200) {
818+
const downloadResult = await this.download(client, source, writeStream);
819+
if (downloadResult.status === 200) {
781820
try {
782821
await pgp.verifySignature(
783822
publicKeys,
@@ -806,7 +845,7 @@ export class CliManager {
806845
this.output.info("Binary will be ran anyway at user request");
807846
}
808847
}
809-
return status;
848+
return downloadResult.status;
810849
}
811850

812851
/**

src/core/container.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,25 @@ export class ServiceContainer implements vscode.Disposable {
5151
context.globalState,
5252
this.logger,
5353
);
54+
55+
const sessionId = newSessionId();
56+
const localJsonlSink = LocalJsonlSink.start(
57+
{
58+
baseDir: this.pathResolver.getTelemetryPath(),
59+
sessionId,
60+
},
61+
this.logger,
62+
);
63+
const session = buildSession(
64+
extractExtensionVersion(context.extension.packageJSON),
65+
sessionId,
66+
);
67+
this.telemetryService = new TelemetryService(
68+
session,
69+
[localJsonlSink],
70+
this.logger,
71+
);
72+
5473
// Circular ref: cliCredentialManager ↔ cliManager. The resolver
5574
// closure captures `this` by reference, so `this.cliManager` is
5675
// available when the closure is called (after construction).
@@ -75,6 +94,7 @@ export class ServiceContainer implements vscode.Disposable {
7594
this.logger,
7695
this.pathResolver,
7796
this.cliCredentialManager,
97+
this.telemetryService,
7898
);
7999
this.contextManager = new ContextManager(context);
80100
this.oauthCallback = new OAuthCallback(context.secrets, this.logger);
@@ -96,23 +116,6 @@ export class ServiceContainer implements vscode.Disposable {
96116
this.logger,
97117
);
98118

99-
const sessionId = newSessionId();
100-
const localJsonlSink = LocalJsonlSink.start(
101-
{
102-
baseDir: this.pathResolver.getTelemetryPath(),
103-
sessionId,
104-
},
105-
this.logger,
106-
);
107-
const session = buildSession(
108-
extractExtensionVersion(context.extension.packageJSON),
109-
sessionId,
110-
);
111-
this.telemetryService = new TelemetryService(
112-
session,
113-
[localJsonlSink],
114-
this.logger,
115-
);
116119
this.commandManager = new CommandManager(this.telemetryService);
117120
}
118121

src/extension.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ServiceContainer } from "./core/container";
1414
import { DeploymentManager } from "./deployment/deploymentManager";
1515
import { CertificateError } from "./error/certificateError";
1616
import { getErrorDetail, toError } from "./error/errorUtils";
17+
import { ActivationTelemetry } from "./instrumentation/activation";
1718
import { OAuthSessionManager } from "./oauth/sessionManager";
1819
import { Remote } from "./remote/remote";
1920
import { getRemoteSshExtension } from "./remote/sshExtension";
@@ -30,6 +31,22 @@ const MY_WORKSPACES_TREE_ID = "myWorkspaces";
3031
const ALL_WORKSPACES_TREE_ID = "allWorkspaces";
3132

3233
export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
34+
const serviceContainer = new ServiceContainer(ctx);
35+
ctx.subscriptions.push(serviceContainer);
36+
const activationTelemetry = new ActivationTelemetry(
37+
serviceContainer.getTelemetryService(),
38+
);
39+
40+
await activationTelemetry.trace(() =>
41+
doActivate(ctx, serviceContainer, activationTelemetry),
42+
);
43+
}
44+
45+
async function doActivate(
46+
ctx: vscode.ExtensionContext,
47+
serviceContainer: ServiceContainer,
48+
activationTelemetry: ActivationTelemetry,
49+
): Promise<void> {
3350
// The Remote SSH extension's proposed APIs are used to override the SSH host
3451
// name in VS Code itself. It's visually unappealing having a lengthy name!
3552
//
@@ -59,9 +76,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
5976
// Initialize the global vscodeProposed module for use throughout the extension
6077
initVscodeProposed(vscodeProposed);
6178

62-
const serviceContainer = new ServiceContainer(ctx);
63-
ctx.subscriptions.push(serviceContainer);
64-
6579
const output = serviceContainer.getLogger();
6680
const mementoManager = serviceContainer.getMementoManager();
6781
const secretsManager = serviceContainer.getSecretsManager();
@@ -75,6 +89,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
7589
const startupMode = await mementoManager.getAndClearStartupMode();
7690

7791
const deployment = await secretsManager.getCurrentDeployment();
92+
const deploymentSessionAuth = deployment
93+
? await secretsManager.getSessionAuth(deployment.safeHostname)
94+
: undefined;
95+
activationTelemetry.setAuthState(
96+
deploymentSessionAuth ? "valid_token" : "none",
97+
);
7898

7999
// Shared handler for auth failures (used by interceptor + session manager)
80100
const handleAuthFailure = (): Promise<void> => {
@@ -111,8 +131,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
111131
// in commands that operate on the current login.
112132
const client = CoderApi.create(
113133
deployment?.url || "",
114-
(await secretsManager.getSessionAuth(deployment?.safeHostname ?? ""))
115-
?.token,
134+
deploymentSessionAuth?.token,
116135
output,
117136
);
118137
ctx.subscriptions.push(client);
@@ -372,11 +391,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
372391
if (details) {
373392
ctx.subscriptions.push(details);
374393

375-
await deploymentManager.setDeploymentIfValid({
394+
const deploymentSet = await deploymentManager.setDeploymentIfValid({
376395
safeHostname: details.safeHostname,
377396
url: details.url,
378397
token: details.token,
379398
});
399+
activationTelemetry.setAuthState(
400+
deploymentSet ? "valid_token" : "expired",
401+
);
380402

381403
// If a deep link stored a chat agent ID before the
382404
// remote-authority reload, open it now that the
@@ -391,7 +413,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
391413
} catch (ex) {
392414
if (ex instanceof CertificateError) {
393415
output.warn(ex.detail);
394-
await ex.showNotification("Failed to open workspace", { modal: true });
416+
await ex.showNotification("Failed to open workspace", {
417+
modal: true,
418+
});
395419
} else if (isAxiosError(ex)) {
396420
const msg = getErrorMessage(ex, "None");
397421
const detail = getErrorDetail(ex) || "None";
@@ -432,9 +456,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
432456
contextManager.set("coder.loaded", true);
433457
} else if (deployment) {
434458
output.info(`Initializing deployment: ${deployment.url}`);
435-
deploymentManager
436-
.setDeploymentIfValid(deployment)
437-
// Failure is logged internally
459+
activationTelemetry
460+
.traceDeploymentInit(() =>
461+
deploymentManager.setDeploymentIfValid(deployment),
462+
)
438463
.then((success) => {
439464
if (success) {
440465
output.info("Deployment authenticated and set");

0 commit comments

Comments
 (0)