diff --git a/src/plugins/setup.ts b/src/plugins/setup.ts index 16e4bdf..631954b 100644 --- a/src/plugins/setup.ts +++ b/src/plugins/setup.ts @@ -5,6 +5,7 @@ import { checkIsAuthenticated, requestAuthentication, saveAuthToken, + readAuthToken, } from "../utils/authenticate.ts"; import { configureAwsProfiles } from "../utils/configure-aws.ts"; import { runInstallProcess } from "../utils/install.ts"; @@ -15,6 +16,7 @@ import { } from "../utils/license.ts"; import { minDelay } from "../utils/promises.ts"; import { updateDockerImage } from "../utils/setup.ts"; +import { get_setup_ended } from "../utils/telemetry.ts"; export default createPlugin( "setup", @@ -38,26 +40,6 @@ export default createPlugin( payload: { namespace: "onboarding", origin: origin_trigger, - expected_steps: [ - { - name: "emulator_installed", - is_first_step: true, - is_last_step: false, - position: 1, - }, - { - name: "auth_token_configured", - is_first_step: false, - is_last_step: false, - position: 2, - }, - { - name: "aws_profile_configured", - is_first_step: false, - is_last_step: true, - position: 3, - }, - ], }, }); @@ -69,27 +51,39 @@ export default createPlugin( }, async (progress, cancellationToken) => { ///////////////////////////////////////////////////////////////////// + let cliStatus: "COMPLETED" | "SKIPPED" = "COMPLETED"; + let authenticationStatus: "COMPLETED" | "SKIPPED" = "COMPLETED"; { const installationStartedAt = new Date().toISOString(); - const { cancelled } = await runInstallProcess( + const { cancelled, skipped } = await runInstallProcess( progress, cancellationToken, outputChannel, telemetry, origin_trigger, ); + cliStatus = skipped === true ? "SKIPPED" : "COMPLETED"; if (cancelled || cancellationToken.isCancellationRequested) { telemetry.track({ name: "emulator_installed", payload: { namespace: "onboarding", origin: origin_trigger, - position: 1, + step_order: 1, started_at: installationStartedAt, ended_at: new Date().toISOString(), status: "CANCELLED", }, }); + telemetry.track( + get_setup_ended( + cliStatus, + "SKIPPED", + "SKIPPED", + "SKIPPED", + "CANCELLED", + ), + ); return; } } @@ -110,30 +104,45 @@ export default createPlugin( const authenticated = await minDelay(checkIsAuthenticated()); if (cancellationToken.isCancellationRequested) { telemetry.track({ - name: "setup_ended", + name: "auth_token_configured", payload: { namespace: "onboarding", - steps: [1, 2, 3], + origin: origin_trigger, + step_order: 2, + started_at: authStartedAuthAt, + ended_at: new Date().toISOString(), status: "CANCELLED", }, }); + telemetry.track( + get_setup_ended( + cliStatus, + "CANCELLED", + "SKIPPED", + "SKIPPED", + "CANCELLED", + await readAuthToken(), + ), + ); return; } if (authenticated) { progress.report({ message: "Skipping authentication...", }); + authenticationStatus = "SKIPPED"; telemetry.track({ name: "auth_token_configured", payload: { namespace: "onboarding", origin: origin_trigger, - position: 2, + step_order: 2, started_at: authStartedAuthAt, ended_at: new Date().toISOString(), status: "SKIPPED", }, }); + await minDelay(Promise.resolve()); } else { ///////////////////////////////////////////////////////////////////// @@ -153,13 +162,23 @@ export default createPlugin( payload: { namespace: "onboarding", origin: origin_trigger, - position: 2, + step_order: 2, auth_token: authToken, started_at: authStartedAuthAt, ended_at: new Date().toISOString(), status: "CANCELLED", }, }); + telemetry.track( + get_setup_ended( + cliStatus, + "CANCELLED", + "SKIPPED", + "SKIPPED", + "CANCELLED", + await readAuthToken(), + ), + ); return; } @@ -174,14 +193,23 @@ export default createPlugin( payload: { namespace: "onboarding", origin: origin_trigger, - position: 2, + step_order: 2, auth_token: authToken, started_at: authStartedAuthAt, ended_at: new Date().toISOString(), status: "CANCELLED", }, }); - + telemetry.track( + get_setup_ended( + cliStatus, + "CANCELLED", + "SKIPPED", + "SKIPPED", + "CANCELLED", + authToken, + ), + ); return; } } @@ -193,6 +221,7 @@ export default createPlugin( // then there will be no license info to be reported by `localstack license info`. // Also, an expired license could be cached. // Activating the license pre-emptively to know its state during the setup process. + const licenseCheckStartedAt = new Date().toISOString(); const licenseIsValid = await minDelay( activateLicense(outputChannel).then(() => checkIsLicenseValid(outputChannel), @@ -213,10 +242,43 @@ export default createPlugin( } if (cancellationToken.isCancellationRequested) { + telemetry.track({ + name: "license_setup_ended", + payload: { + namespace: "onboarding", + step_order: 3, + origin: origin_trigger, + auth_token: await readAuthToken(), + started_at: licenseCheckStartedAt, + ended_at: new Date().toISOString(), + status: "CANCELLED", + }, + }); + telemetry.track( + get_setup_ended( + cliStatus, + authenticationStatus, + "CANCELLED", + "SKIPPED", + "CANCELLED", + await readAuthToken(), + ), + ); return; } - //TODO add telemetry + telemetry.track({ + name: "license_setup_ended", + payload: { + namespace: "onboarding", + step_order: 3, + origin: origin_trigger, + auth_token: await readAuthToken(), + started_at: licenseCheckStartedAt, + ended_at: new Date().toISOString(), + status: "COMPLETED", + }, + }); ///////////////////////////////////////////////////////////////////// progress.report({ @@ -245,14 +307,16 @@ export default createPlugin( } if (cancellationToken.isCancellationRequested) { - telemetry.track({ - name: "setup_ended", - payload: { - namespace: "onboarding", - steps: [1, 2, 3], - status: "CANCELLED", - }, - }); + telemetry.track( + get_setup_ended( + cliStatus, + authenticationStatus, + "COMPLETED", + "COMPLETED", + "CANCELLED", + await readAuthToken(), + ), + ); return; } @@ -281,14 +345,16 @@ export default createPlugin( }); } - telemetry.track({ - name: "setup_ended", - payload: { - namespace: "onboarding", - steps: [1, 2, 3], - status: "COMPLETED", - }, - }); + telemetry.track( + get_setup_ended( + cliStatus, + authenticationStatus, + "COMPLETED", + "COMPLETED", + "COMPLETED", + await readAuthToken(), + ), + ); }, ); }, diff --git a/src/utils/authenticate.ts b/src/utils/authenticate.ts index b849844..2838e92 100644 --- a/src/utils/authenticate.ts +++ b/src/utils/authenticate.ts @@ -124,34 +124,39 @@ export async function saveAuthToken( } } -/** - * Checks if the user is authenticated by validating the stored auth token. - * - * License is validated separately - * - * @returns boolean indicating if the authentication is valid - */ -export async function checkIsAuthenticated() { +function isAuthTokenPresent(authObject: unknown) { + return ( + typeof authObject === "object" && + authObject !== null && + AUTH_TOKEN_KEY in authObject + ); +} + +// Reads the auth token from the auth.json file for logging in the user +export async function readAuthToken(): Promise { try { const authJson = await fs.readFile(LOCALSTACK_AUTH_FILENAME, "utf-8"); const authObject = JSON.parse(authJson) as unknown; if (!isAuthTokenPresent(authObject)) { - return false; + return ""; } const authToken = authObject[AUTH_TOKEN_KEY]; if (typeof authToken !== "string") { - return false; + return ""; } - return true; - } catch (error) { - return false; + return authToken; + } catch { + return ""; } } -function isAuthTokenPresent(authObject: unknown) { - return ( - typeof authObject === "object" && - authObject !== null && - AUTH_TOKEN_KEY in authObject - ); +/** + * Checks if the user is authenticated by validating the stored auth token. + * + * License is validated separately + * + * @returns boolean indicating if the authentication is valid + */ +export async function checkIsAuthenticated() { + return (await readAuthToken()) !== ""; } diff --git a/src/utils/configure-aws.ts b/src/utils/configure-aws.ts index 44edab6..5bf81d8 100644 --- a/src/utils/configure-aws.ts +++ b/src/utils/configure-aws.ts @@ -6,6 +6,7 @@ import * as path from "node:path"; import { window } from "vscode"; import type { LogOutputChannel } from "vscode"; +import { readAuthToken } from "./authenticate.ts"; import { parseIni, serializeIni, updateIniSection } from "./ini-parser.ts"; import type { IniFile, IniSection } from "./ini-parser.ts"; import type { Telemetry } from "./telemetry.ts"; @@ -318,6 +319,8 @@ export async function configureAwsProfiles(options: { const credentialsNeedsOverride = checkIfCredentialsNeedsOverride(credentialsSection); + const authToken = await readAuthToken(); + // means sections exist, but we need to check what's inside if (credentialsSection && configSection) { if (!configNeedsOverride && !credentialsNeedsOverride) { @@ -332,10 +335,11 @@ export async function configureAwsProfiles(options: { payload: { namespace: "onboarding", origin: trigger, - position: 3, + step_order: 4, started_at: startedAt, ended_at: new Date().toISOString(), status: "COMPLETED", + auth_token: authToken, }, }); return; @@ -365,10 +369,11 @@ export async function configureAwsProfiles(options: { payload: { namespace: "onboarding", origin: trigger, - position: 3, + step_order: 4, started_at: startedAt, ended_at: new Date().toISOString(), status: "SKIPPED", + auth_token: authToken, }, }); return; @@ -399,10 +404,11 @@ export async function configureAwsProfiles(options: { payload: { namespace: "onboarding", origin: trigger, - position: 3, + step_order: 4, started_at: startedAt, ended_at: new Date().toISOString(), status: "COMPLETED", + auth_token: authToken, }, }); } else if (configNeedsOverride) { @@ -422,10 +428,11 @@ export async function configureAwsProfiles(options: { payload: { namespace: "onboarding", origin: trigger, - position: 3, + step_order: 4, started_at: startedAt, ended_at: new Date().toISOString(), status: "COMPLETED", + auth_token: authToken, }, }); } else if (credentialsNeedsOverride) { @@ -444,10 +451,11 @@ export async function configureAwsProfiles(options: { payload: { namespace: "onboarding", origin: trigger, - position: 3, + step_order: 4, started_at: startedAt, ended_at: new Date().toISOString(), status: "COMPLETED", + auth_token: authToken, }, }); } diff --git a/src/utils/install.ts b/src/utils/install.ts index 21a6220..7acfaaf 100644 --- a/src/utils/install.ts +++ b/src/utils/install.ts @@ -40,7 +40,7 @@ export async function runInstallProcess( outputChannel: LogOutputChannel, telemetry: Telemetry, origin?: "extension_startup" | "manual_trigger", -): Promise<{ cancelled: boolean }> { +): Promise<{ cancelled: boolean; skipped?: boolean }> { ///////////////////////////////////////////////////////////////////// const origin_trigger = origin ? origin : "manual_trigger"; progress.report({ @@ -64,14 +64,14 @@ export async function runInstallProcess( payload: { namespace: "onboarding", origin: origin_trigger, - position: 1, + step_order: 1, started_at: startedAt, ended_at: new Date().toISOString(), status: "SKIPPED", }, }); await minDelay(); - return { cancelled: false }; + return { cancelled: false, skipped: true }; } ///////////////////////////////////////////////////////////////////// @@ -148,7 +148,7 @@ export async function runInstallProcess( payload: { namespace: "onboarding", origin: origin_trigger, - position: 1, + step_order: 1, started_at: startedAt, ended_at: new Date().toISOString(), status: "FAILED", @@ -177,7 +177,7 @@ export async function runInstallProcess( payload: { namespace: "onboarding", origin: origin_trigger, - position: 1, + step_order: 1, started_at: startedAt, ended_at: new Date().toISOString(), status: "FAILED", @@ -194,7 +194,7 @@ export async function runInstallProcess( payload: { namespace: "onboarding", origin: origin_trigger, - position: 1, + step_order: 1, started_at: startedAt, ended_at: new Date().toISOString(), status: "COMPLETED", diff --git a/src/utils/manage.ts b/src/utils/manage.ts index 2a9931e..746d369 100644 --- a/src/utils/manage.ts +++ b/src/utils/manage.ts @@ -2,6 +2,7 @@ import { v7 as uuidv7 } from "uuid"; import type { ExtensionContext, LogOutputChannel, MessageItem } from "vscode"; import { commands, env, Uri, window } from "vscode"; +import { readAuthToken } from "./authenticate.ts"; import { spawnLocalStack } from "./cli.ts"; import { exec } from "./exec.ts"; import { checkIsLicenseValid } from "./license.ts"; @@ -22,18 +23,21 @@ export async function fetchHealth(): Promise { return false; } } - async function fetchLocalStackSessionId(): Promise { - try { - // TODO info endpoint is not available immediately - // potentially improve this later for tracking "vscode:emulator:started" - const infoResponse = await fetch("http://127.0.0.1:4566/_localstack/info"); - if (infoResponse.ok) { - const info = (await infoResponse.json()) as { session_id?: string }; - return info.session_id ?? ""; + // retry a few times to allow LocalStack to start up and info become available + for (let attempt = 0; attempt < 10; attempt++) { + try { + const response = await fetch("http://127.0.0.1:4566/_localstack/info"); + if (response.ok) { + const json = await response.json(); + if (typeof json === "object" && json !== null && "session_id" in json) { + return typeof json.session_id === "string" ? json.session_id : ""; + } + } + } catch { + // ignore error and retry } - } catch { - // unable to fetch session id + await new Promise((resolve) => setTimeout(resolve, 1000)); } return ""; } @@ -91,6 +95,7 @@ export async function startLocalStack( command: "localstack.viewLogs", }); + const authToken = await readAuthToken(); try { await spawnLocalStack( [ @@ -129,6 +134,7 @@ export async function startLocalStack( namespace: "emulator", status: "COMPLETED", emulator_session_id: emulatorSessionId, + auth_token: authToken, }, }); } catch (error) { @@ -152,6 +158,7 @@ export async function startLocalStack( namespace: "emulator", status: "FAILED", errors: [String(error)], + auth_token: authToken, }, }); } @@ -163,6 +170,7 @@ export async function stopLocalStack( ) { void showInformationMessage("Stopping LocalStack."); + const authToken = await readAuthToken(); try { // get session id before killing container const emulatorSessionId = await fetchLocalStackSessionId(); @@ -177,6 +185,7 @@ export async function stopLocalStack( namespace: "emulator", status: "COMPLETED", emulator_session_id: emulatorSessionId, + auth_token: authToken, }, }); } catch (error) { @@ -191,6 +200,7 @@ export async function stopLocalStack( namespace: "emulator", status: "FAILED", errors: [String(error)], + auth_token: authToken, }, }); } diff --git a/src/utils/telemetry.ts b/src/utils/telemetry.ts index 2fd4fa4..01a8034 100644 --- a/src/utils/telemetry.ts +++ b/src/utils/telemetry.ts @@ -3,7 +3,7 @@ import os from "node:os"; import type { LogOutputChannel } from "vscode"; import { extensions, version as vscodeVersion, workspace } from "vscode"; -const SCHEMA_VERSION = 1; +const SCHEMA_VERSION = 2; const ANALYTICS_API_URL = process.env.ANALYTICS_API_URL ?? @@ -17,12 +17,6 @@ type Events = payload: { namespace: "onboarding"; origin: "manual_trigger" | "extension_startup"; - expected_steps: { - name: string; - is_first_step: boolean; - is_last_step: boolean; - position: number; - }[]; }; } | { @@ -30,7 +24,7 @@ type Events = payload: { namespace: "onboarding"; origin: "manual_trigger" | "extension_startup"; - position: 1; + step_order: 1; started_at: string; ended_at: string; status: "COMPLETED" | "FAILED" | "SKIPPED" | "CANCELLED"; @@ -43,7 +37,20 @@ type Events = payload: { namespace: "onboarding"; origin: "manual_trigger" | "extension_startup"; - position: 2; + step_order: 2; + auth_token?: string; + started_at: string; + ended_at: string; + status: "COMPLETED" | "FAILED" | "SKIPPED" | "CANCELLED"; + errors?: string[]; + }; + } + | { + name: "license_setup_ended"; + payload: { + namespace: "onboarding"; + origin: "manual_trigger" | "extension_startup"; + step_order: 3; auth_token?: string; started_at: string; ended_at: string; @@ -56,19 +63,50 @@ type Events = payload: { namespace: "onboarding"; origin: "manual_trigger" | "extension_startup"; - position: 3; + step_order: 4; started_at: string; ended_at: string; status: "COMPLETED" | "FAILED" | "SKIPPED" | "CANCELLED"; errors?: string[]; + auth_token: string; }; } | { name: "setup_ended"; payload: { namespace: "onboarding"; - steps: number[]; + steps: [ + { + name: "emulator_installed"; + is_first_step: true; + is_last_step: false; + step_order: 1; + status: "COMPLETED" | "FAILED" | "SKIPPED" | "CANCELLED"; + }, + { + name: "auth_token_configured"; + is_first_step: false; + is_last_step: false; + step_order: 2; + status: "COMPLETED" | "FAILED" | "SKIPPED" | "CANCELLED"; + }, + { + name: "license_setup_ended"; + is_first_step: false; + is_last_step: false; + step_order: 3; + status: "COMPLETED" | "FAILED" | "SKIPPED" | "CANCELLED"; + }, + { + name: "aws_profile_configured"; + is_first_step: false; + is_last_step: true; + step_order: 4; + status: "COMPLETED" | "FAILED" | "SKIPPED" | "CANCELLED"; + }, + ]; status: "COMPLETED" | "FAILED" | "CANCELLED"; + auth_token?: string; }; } | { @@ -78,6 +116,7 @@ type Events = status: "COMPLETED" | "FAILED"; emulator_session_id?: string; errors?: string[]; + auth_token: string; }; } | { @@ -87,6 +126,7 @@ type Events = status: "COMPLETED" | "FAILED"; emulator_session_id?: string; errors?: string[]; + auth_token: string; }; }; @@ -173,3 +213,51 @@ export function createTelemetry( }, }; } + +export function get_setup_ended( + cli_status: "COMPLETED" | "SKIPPED" | "CANCELLED", + authentication_status: "COMPLETED" | "SKIPPED" | "CANCELLED", + license_setup_status: "COMPLETED" | "SKIPPED" | "CANCELLED", + aws_profile_status: "COMPLETED" | "SKIPPED" | "CANCELLED", + overall_status: "CANCELLED" | "COMPLETED", + auth_token: string = "", +): Events { + return { + name: "setup_ended", + payload: { + namespace: "onboarding", + steps: [ + { + name: "emulator_installed", + is_first_step: true, + is_last_step: false, + step_order: 1, + status: cli_status, + }, + { + name: "auth_token_configured", + is_first_step: false, + is_last_step: false, + step_order: 2, + status: authentication_status, + }, + { + name: "license_setup_ended", + is_first_step: false, + is_last_step: false, + step_order: 3, + status: license_setup_status, + }, + { + name: "aws_profile_configured", + is_first_step: false, + is_last_step: true, + step_order: 4, + status: aws_profile_status, + }, + ], + status: overall_status, + auth_token, + }, + }; +}