diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 492d9dd702..3b6875d5d5 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -3,6 +3,7 @@ import path from "path" import { rm } from "node:fs/promises" import { Global } from "../../global" import { Filesystem } from "../../util/filesystem" +import { InstallationVersion } from "@opencode-ai/core/installation/version" const DEFAULT_MCP_URL = "https://mcpserver.getaltimate.com/sse" // altimate_change start — default Altimate API URL when user omits it from the TUI credential entry @@ -66,6 +67,12 @@ export namespace AltimateApi { return path.join(Global.Path.home, ".altimate", "altimate.json") } + /** Names this client on every backend call so the server can attribute create and link + * calls to altimate-code rather than to the web app or the extension. */ + export function clientHeaders(): Record { + return { "x-altimate-client": `altimate-code/${InstallationVersion}` } + } + export async function isConfigured(): Promise { return Filesystem.exists(credentialsPath()) } @@ -185,6 +192,7 @@ export namespace AltimateApi { "x-tenant": creds.altimateInstanceName, Authorization: `Bearer ${creds.altimateApiKey}`, "Content-Type": "application/json", + ...clientHeaders(), }, // altimate_change start — upstream_fix: attach abort signal signal: controller.signal, @@ -224,6 +232,7 @@ export namespace AltimateApi { headers: { "x-tenant": instance, "Content-Type": "application/json", + ...clientHeaders(), }, body: JSON.stringify({ token }), signal: controller.signal, @@ -252,6 +261,7 @@ export namespace AltimateApi { "Content-Type": "application/json", Authorization: `Bearer ${creds.altimateApiKey}`, "x-tenant": creds.altimateInstanceName, + ...clientHeaders(), }, ...(body ? { body: JSON.stringify(body) } : {}), }) diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index e67ff13311..b9e0fbe4fc 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -5,6 +5,7 @@ import open from "open" import { AltimateApi } from "../api/client" // altimate_change — onboarding telemetry for the gateway sign-in funnel import * as OnboardingTelemetry from "../telemetry/onboarding" +import { Telemetry } from "../telemetry" // altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped) import { getOrCreateMachineId } from "../util/machine-id" import { Config } from "@/config/config" @@ -491,6 +492,10 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise { altimateInstanceName: creds.instance, altimateApiKey: authToken, }) + // altimate_change start — telemetry is already running in this process; stamp the + // user id and tenant of the account just signed in on the events that follow. + void Telemetry.refreshIdentity().catch(() => {}) + // altimate_change end // altimate_change start — onboarding funnel: auth succeeded and the instance // is live. The instance name is the customer's tenant identifier and is never // sent. time_to_connect_ms runs from the start of authorize() — before the diff --git a/packages/opencode/src/altimate/telemetry/identity.ts b/packages/opencode/src/altimate/telemetry/identity.ts new file mode 100644 index 0000000000..bca2437b4e --- /dev/null +++ b/packages/opencode/src/altimate/telemetry/identity.ts @@ -0,0 +1,197 @@ +import fs from "fs" +import path from "path" +import { rm } from "node:fs/promises" +import { createHash } from "crypto" +import { Global } from "../../global" +import { Filesystem } from "../../util/filesystem" +import { Auth } from "../../auth" +import { AltimateApi } from "@/altimate/api/client" +import { Log } from "@/altimate/util/log" + +const log = Log.create({ service: "telemetry-identity" }) + +/** + * Who this machine is signed in as, for telemetry. + * + * Web events in PostHog are keyed by the backend user uuid; CLI events were keyed by the + * machine only, so nothing joined a CLI session to the account that created or linked the + * workspace it uses. This resolves the backend user once per credential (``GET /users/me``, + * the same call the workspace client makes) and hands telemetry a ``user_id`` and ``tenant`` + * to stamp on every event. No email, hashed or not, leaves the client. + * + * The credential is the one the Altimate provider uses: ``~/.altimate/altimate.json`` first, + * else the ``altimate-backend`` entry of the auth store (a key entered in the TUI). The result + * is cached at ``~/.altimate/telemetry-identity.json`` under a fingerprint of the credential, so + * a process only hits the network when the credential changed. Every failure resolves to + * ``undefined``: telemetry must never block or break on identity. + */ +export namespace TelemetryIdentity { + export type Identity = { user_id: string; tenant: string } + + type Credential = { altimateUrl: string; altimateInstanceName: string; altimateApiKey: string } + type Cached = Identity & { fingerprint: string } + + const REQUEST_TIMEOUT_MS = 10_000 + const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + const AUTH_STORE_KEY = "altimate-backend" + + export function cachePath(): string { + return path.join(Global.Path.home, ".altimate", "telemetry-identity.json") + } + + /** True when a credential is in use (file or auth store), whatever its validity. */ + export async function hasCredential(): Promise { + return (await loadCredential()) !== undefined + } + + /** The credential the Altimate provider would use, or undefined when signed out. */ + export async function loadCredential(): Promise { + try { + if (await AltimateApi.isConfigured()) { + const c = await AltimateApi.getCredentials() + return { + altimateUrl: c.altimateUrl, + altimateInstanceName: c.altimateInstanceName, + altimateApiKey: c.altimateApiKey, + } + } + const auth = await Auth.get(AUTH_STORE_KEY) + if (auth?.type === "api") { + const parsed = AltimateApi.parseAltimateKey(auth.key) + // Same normalisation as getCredentials(): a trailing slash would yield "//users/me". + return parsed ? { ...parsed, altimateUrl: parsed.altimateUrl.replace(/\/+$/, "") } : undefined + } + return undefined + } catch { + return undefined + } + } + + /** + * A cheap stamp of the two credential files (mtime and size), so a flush can notice that a + * sign-in, sign-out or key change happened on disk, in this thread or another, without + * reading or parsing anything. + */ + export function credentialStamp(): string { + const stamp = (p: string) => { + try { + const s = fs.statSync(p) + return `${s.mtimeMs}:${s.size}` + } catch { + return "absent" + } + } + return `${stamp(AltimateApi.credentialsPath())}|${stamp(Auth.filepath())}` + } + + function fingerprint(c: Credential): string { + return createHash("sha256") + .update(`${c.altimateUrl}\n${c.altimateInstanceName}\n${c.altimateApiKey}`) + .digest("hex") + .slice(0, 16) + } + + async function readCache(): Promise { + try { + const p = cachePath() + if (!(await Filesystem.exists(p))) return undefined + const raw = JSON.parse(await Filesystem.readText(p)) as Partial + if (typeof raw.fingerprint !== "string" || typeof raw.user_id !== "string" || typeof raw.tenant !== "string") + return undefined + if (!UUID.test(raw.user_id)) return undefined + return { fingerprint: raw.fingerprint, user_id: raw.user_id, tenant: raw.tenant } + } catch { + return undefined + } + } + + /** The cached identity, if it belongs to the credential currently in use. No network. */ + export async function cached(): Promise { + try { + const creds = await loadCredential() + if (!creds) return undefined + const hit = await readCache() + if (!hit || hit.fingerprint !== fingerprint(creds)) return undefined + return { user_id: hit.user_id, tenant: hit.tenant } + } catch { + return undefined + } + } + + /** + * Resolve the signed-in user. Uses the cache when the credential has not changed; otherwise + * asks the backend and caches the answer. ``refresh`` forces the request (after a sign-in). + * A backend rejection (401 or 403) drops the cache for that credential; a transient failure + * keeps it. The answer is written and returned only if the same credential is still in use. + */ + export async function resolve(opts: { refresh?: boolean } = {}): Promise { + try { + const creds = await loadCredential() + if (!creds) { + await clear() + return undefined + } + const fp = fingerprint(creds) + if (!opts.refresh) { + const hit = await readCache() + if (hit && hit.fingerprint === fp) return { user_id: hit.user_id, tenant: hit.tenant } + } + const answer = await fetchMe(creds) + if (answer.kind === "rejected") { + const hit = await readCache() + if (hit && hit.fingerprint === fp) await clear() + return undefined + } + if (answer.kind !== "ok") return undefined + const now = await loadCredential() + if (!now || fingerprint(now) !== fp) return undefined + const identity: Identity = { user_id: answer.uuid, tenant: creds.altimateInstanceName } + try { + await Filesystem.writeJson(cachePath(), { ...identity, fingerprint: fp } satisfies Cached, 0o600) + } catch (err) { + log.warn("telemetry identity cache not written", { err: String(err) }) + } + return identity + } catch (err) { + log.warn("telemetry identity unresolved", { err: String(err) }) + return undefined + } + } + + /** Forget the cached identity (sign-out). */ + export async function clear(): Promise { + try { + await rm(cachePath(), { force: true }) + } catch { + // nothing to forget + } + } + + type Answer = { kind: "ok"; uuid: string } | { kind: "rejected" } | { kind: "error" } + + async function fetchMe(c: Credential): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + try { + const res = await fetch(`${c.altimateUrl}/users/me`, { + method: "GET", + headers: { + Authorization: `Bearer ${c.altimateApiKey}`, + "x-tenant": c.altimateInstanceName, + ...AltimateApi.clientHeaders(), + }, + signal: controller.signal, + }) + if (res.status === 401 || res.status === 403) return { kind: "rejected" } + if (!res.ok) return { kind: "error" } + const body = (await res.json()) as { uuid?: unknown } + return typeof body?.uuid === "string" && UUID.test(body.uuid) + ? { kind: "ok", uuid: body.uuid.toLowerCase() } + : { kind: "error" } + } catch { + return { kind: "error" } + } finally { + clearTimeout(timeout) + } + } +} diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index f350d9783c..d4116b5eca 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1,4 +1,3 @@ -import { Account } from "@/account" import { Config } from "@/config/config" import { Flag } from "@/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -6,6 +5,7 @@ import { Log } from "@/altimate/util/log" import { TraceContext } from "@/altimate/observability/trace-context" // altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped) import { getOrCreateMachineId } from "@/altimate/util/machine-id" +import { TelemetryIdentity } from "./identity" import { createHash, randomUUID } from "crypto" // altimate_change — first-run health: the event-loop stall monitor labels which thread stalled import { isMainThread } from "node:worker_threads" @@ -1851,7 +1851,25 @@ export namespace Telemetry { let enabled = false let buffer: Event[] = [] let flushTimer: ReturnType | undefined - let userEmail = "" + // altimate_change — who the machine is signed in as (backend user uuid + tenant), for the + // funnel join with web events. Resolved off the stored credential in doInit(); see identity.ts. + let identity: TelemetryIdentity.Identity | undefined + // altimate_change — resolutions are detached (doInit does not await the backend), so each one + // carries the generation it started in and applies only while it is still the newest: a + // resolution started under credential A must not land after a sign-in installed B, nor after + // shutdown() cleared the state. shutdown() and every new resolution bump it. + let identityGeneration = 0 + // altimate_change — stamp of the credential files when the current identity was resolved; a + // flush that sees a different stamp re-resolves, so a sign-in, sign-out or key change made on + // disk by any path (manual file edit, the TUI, another thread's auth flow) is picked up within + // one flush interval instead of at the next process start. + let identityStamp = "" + // altimate_change — a resolution that failed while a credential exists (backend down, timeout) + // is tried again at the first flush after this time, not on every 5 s flush. The stamp of the + // failed credential is kept, so a different credential appearing in the meantime is noticed at + // once by the stamp comparison; the backoff only throttles retries of the unchanged one. + const IDENTITY_RETRY_MS = 60_000 + let identityRetryAt = 0 // 0: no retry pending let machineId = "" let sessionId = "" let projectId = "" @@ -1920,7 +1938,16 @@ export namespace Telemetry { return { iKey, endpoint: `${base}v2/track` } } - function toAppInsightsEnvelopes(events: Event[], cfg: AppInsightsConfig): object[] { + // altimate_change — `ids` lets a caller build envelopes for a machine and identity other than + // the module's current state (the opt-out marker runs while telemetry is disabled and must not + // touch the state an enabled init may be using concurrently). + function toAppInsightsEnvelopes( + events: Event[], + cfg: AppInsightsConfig, + ids?: { machineId: string; identity: TelemetryIdentity.Identity | undefined }, + ): object[] { + const mid = ids ? ids.machineId : machineId + const who = ids ? ids.identity : identity // Process-level client, read live from the env flag (defaults to "cli") so events // emitted before any prompt runs — startup, connection setup, standalone CLI // subcommands — are labelled correctly instead of stuck at a hardcoded default. @@ -1937,7 +1964,10 @@ export namespace Telemetry { cli_version: InstallationVersion, source: clientSource, project_id: fields.project_id ?? projectId, - ...(machineId && { machine_id: machineId }), + ...(mid && { machine_id: mid }), + // altimate_change — the backend user uuid and tenant when signed in to Altimate, so a CLI + // row joins the PostHog person and the backend record for the same account. + ...(who && { user_id: who.user_id, tenant: who.tenant }), // altimate_change — groups every event from one process launch. The onboarding funnel // spans the TUI main thread and the server worker, and most of it runs before any chat // session exists, so `session_id` is empty for the first half and real for the second — @@ -1962,12 +1992,10 @@ export namespace Telemetry { iKey: cfg.iKey, tags: { "ai.session.id": sid || "startup", - // altimate_change start — use machine_id as fallback for anonymous user identification - // This IMPROVES privacy: previously all anonymous users shared ai.user.id="" - // which made them appear as one mega-user in analytics. Using the random UUID - // (already sent as a custom property) gives each machine a distinct identity - // without any PII. machine_id is a crypto.randomUUID() stored locally. - "ai.user.id": userEmail || machineId || "", + // altimate_change start — the backend user uuid when signed in, else the machine id. + // Anonymous machines each keep a distinct identity (the random machine UUID, already a + // custom property) instead of sharing ai.user.id="". No email, hashed or not, is sent. + "ai.user.id": who?.user_id || mid || "", // altimate_change end "ai.cloud.role": "altimate", "ai.application.ver": InstallationVersion, @@ -2073,16 +2101,12 @@ export namespace Telemetry { return } appInsights = cfg - try { - // altimate_change start — bridge merge: Account.active() became async in v1.4.0 - const account = await Account.active() - if (account?.email) { - userEmail = createHash("sha256").update(account.email.toLowerCase().trim()).digest("hex") - } - // altimate_change end - } catch { - // Account unavailable — proceed without user ID - } + // altimate_change start — identity for the funnel join: the backend user uuid and tenant from + // the stored Altimate credential, cached per credential so this costs no network unless the + // credential changed. Replaces the hashed account email. Not awaited: events flushed before it + // resolves carry only the machine id, and telemetry never waits on the backend. + void resolveIdentity() + // altimate_change end // altimate_change — use shared getOrCreateMachineId() from util/machine-id.ts. // Returns "" on all error conditions (ENOENT: mints new UUID; EACCES/corrupt/oversized: // logs + returns ""). No try/catch needed — all paths are handled inside. @@ -2113,6 +2137,44 @@ export namespace Telemetry { } } + // altimate_change start — re-resolve the signed-in user after a sign-in or sign-out in this + // process (the browser login flow saves the credential while telemetry is already running). + function resolveIdentity(opts: { refresh?: boolean } = {}): Promise { + const generation = ++identityGeneration + identityStamp = TelemetryIdentity.credentialStamp() + identityRetryAt = 0 + return TelemetryIdentity.resolve(opts) + .then(async (resolved) => { + if (generation !== identityGeneration) return + identity = resolved + if (resolved === undefined && (await TelemetryIdentity.hasCredential())) { + // Signed in but unresolved: a transient failure. Retry at a later flush. + if (generation === identityGeneration) identityRetryAt = Date.now() + IDENTITY_RETRY_MS + } + }) + .catch(() => {}) + } + + /** Re-resolve who the machine is signed in as; clears it when no credential is stored. Events + * already buffered are flushed under the identity they were tracked with first, and events + * tracked while the new answer is pending carry no account rather than the previous one. */ + export async function refreshIdentity(): Promise { + await flush() + identity = undefined + await resolveIdentity({ refresh: true }) + } + + /** Test seam. */ + export function identityForTest(): TelemetryIdentity.Identity | undefined { + return identity + } + + /** Test seam: end a pending retry backoff so the next flush re-resolves. */ + export function retryIdentityNowForTest() { + if (identityRetryAt !== 0) identityRetryAt = 1 + } + // altimate_change end + export function setContext(opts: { sessionId: string; projectId: string }) { sessionId = opts.sessionId projectId = opts.projectId @@ -2304,6 +2366,20 @@ export namespace Telemetry { } async function doFlush(timeoutMs: number) { + // altimate_change start — this batch is attributed to the identity in force when it is sent, + // captured before the credential check below can start replacing it; then notice a credential + // change on disk and re-resolve (cheap: two stats, and the cache answers unless the key changed). + const who = identity + const mid = machineId + if (enabled) { + const stamp = TelemetryIdentity.credentialStamp() + const retryDue = identityRetryAt !== 0 && Date.now() >= identityRetryAt + if (stamp !== identityStamp || retryDue) { + identity = undefined + void resolveIdentity() + } + } + // altimate_change end if (!enabled || buffer.length === 0 || !appInsights) return const events = buffer.splice(0, buffer.length) @@ -2327,7 +2403,7 @@ export namespace Telemetry { const response = await fetch(appInsights.endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(toAppInsightsEnvelopes(events, appInsights)), + body: JSON.stringify(toAppInsightsEnvelopes(events, appInsights, { machineId: mid, identity: who })), signal: controller.signal, }) if (!response.ok) { @@ -2466,10 +2542,13 @@ export namespace Telemetry { sessionId = "" projectId = "" machineId = "" - // doInit() only assigns userEmail when Account.active() returns one, so without this a - // logout followed by a re-init in the same process kept hashing the previous account into - // ai.user.id. - userEmail = "" + // Cleared so a sign-out followed by a re-init in the same process does not keep stamping the + // previous account on events; doInit() resolves it again from whatever credential is on disk. + // The generation bump makes any resolution still in flight a no-op when it settles. + identityGeneration++ + identity = undefined + identityStamp = "" + identityRetryAt = 0 // NOTE: cachedLaunchId is deliberately NOT cleared here. session/prompt.ts shuts telemetry // down at the end of every session, so clearing it would mint a fresh launch_id per prompt in // a long-lived `serve` process and shatter the per-launch correlation it exists to provide. diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index 47c4a9a676..15df318ff3 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -233,6 +233,7 @@ async function req( "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, "x-tenant": instance, + ...AltimateApi.clientHeaders(), }, signal: controller.signal, ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index a320d6da95..c06d17254d 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -12,6 +12,13 @@ export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" const file = path.join(Global.Path.data, "auth.json") +// altimate_change start — telemetry identity stats this file to notice a sign-in or sign-out made +// through the TUI without parsing the store on every flush. +export function filepath(): string { + return file +} +// altimate_change end + const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) export class Oauth extends Schema.Class("OAuth")({ diff --git a/packages/opencode/test/altimate/telemetry/identity.test.ts b/packages/opencode/test/altimate/telemetry/identity.test.ts new file mode 100644 index 0000000000..5d0747155c --- /dev/null +++ b/packages/opencode/test/altimate/telemetry/identity.test.ts @@ -0,0 +1,383 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, existsSync, writeFileSync } from "fs" +import os from "os" +import path from "path" +import { Telemetry } from "../../../src/altimate/telemetry" +import { Auth } from "../../../src/auth" +import { TelemetryIdentity } from "../../../src/altimate/telemetry/identity" + +const UUID = "0f3c2a1e-6b7d-4c8e-9f10-1a2b3c4d5e6f" +const UUID_B = "7a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d" +const API = "https://api.example.invalid" +const DEFAULT_API = "https://api.myaltimate.com" + +type Call = { url: string; method: string; headers: Record; body: string } + +function headersOf(init?: RequestInit): Record { + const out: Record = {} + const h = init?.headers + if (!h) return out + if (h instanceof Headers) h.forEach((v, k) => (out[k.toLowerCase()] = v)) + else if (Array.isArray(h)) for (const [k, v] of h) out[k.toLowerCase()] = String(v) + else for (const [k, v] of Object.entries(h)) out[k.toLowerCase()] = String(v) + return out +} + +describe("telemetry identity", () => { + let home: string + let calls: Call[] + let fetchSpy: ReturnType + let homedirSpy: ReturnType + let savedTestHome: string | undefined + let savedCs: string | undefined + let meStatus = 200 + /** When set, /users/me answers 200 with this raw body instead of a user. */ + let meBody: string | undefined + let authSpy: ReturnType + /** Per-api-key gates: a /users/me request for that key waits on the promise before answering. */ + let gates: Record | undefined> = {} + /** Resolved when a /users/me request for that key has reached its gate. */ + let entered: Record void> = {} + + function writeCreds(apiKey = "key-1") { + mkdirSync(path.join(home, ".altimate"), { recursive: true }) + writeFileSync( + path.join(home, ".altimate", "altimate.json"), + JSON.stringify({ altimateUrl: API, altimateInstanceName: "acme", altimateApiKey: apiKey }), + ) + } + function meCalls() { + return calls.filter((c) => c.url === `${API}/users/me`) + } + function trackCalls() { + return calls.filter((c) => c.url.includes("/v2/track")) + } + + beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), "telemetry-identity-")) + savedTestHome = process.env.OPENCODE_TEST_HOME + savedCs = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + process.env.OPENCODE_TEST_HOME = home + homedirSpy = spyOn(os, "homedir").mockImplementation(() => home) + calls = [] + meStatus = 200 + meBody = undefined + gates = {} + entered = {} + // The auth store lives in the real data directory; never let a test read it. + authSpy = spyOn(Auth, "get").mockResolvedValue(undefined) + fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: any, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + calls.push({ url, method: init?.method ?? "GET", headers: headersOf(init), body: String(init?.body ?? "") }) + if (url === `${API}/users/me` || url === `${DEFAULT_API}/users/me`) { + const key = headersOf(init)["authorization"]?.replace("Bearer ", "") ?? "" + const gate = gates[key] + entered[key]?.() + if (gate) await gate + if (meStatus !== 200) return new Response("nope", { status: meStatus }) + if (meBody !== undefined) + return new Response(meBody, { status: 200, headers: { "content-type": "application/json" } }) + const uuid = key.startsWith("key-B") ? UUID_B : UUID.toUpperCase() + return new Response(JSON.stringify({ id: 7, uuid, email: "someone@example.invalid" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + return new Response("", { status: 200 }) + }) as any) + }) + + afterEach(async () => { + await Telemetry.shutdown() + fetchSpy.mockRestore() + homedirSpy.mockRestore() + authSpy.mockRestore() + if (savedTestHome === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = savedTestHome + if (savedCs === undefined) delete process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + else process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = savedCs + rmSync(home, { recursive: true, force: true }) + }) + + test("resolves the backend user once per credential and caches it with owner-only permissions", async () => { + writeCreds() + const first = await TelemetryIdentity.resolve() + expect(first).toEqual({ user_id: UUID, tenant: "acme" }) + expect(meCalls()).toHaveLength(1) + const me = meCalls()[0] + expect(me.headers["x-tenant"]).toBe("acme") + expect(me.headers["authorization"]).toBe("Bearer key-1") + expect(me.headers["x-altimate-client"]).toMatch(/^altimate-code\//) + + const cachePath = TelemetryIdentity.cachePath() + expect(cachePath).toBe(path.join(home, ".altimate", "telemetry-identity.json")) + const cached = JSON.parse(readFileSync(cachePath, "utf8")) + expect(cached.user_id).toBe(UUID) + expect(cached.tenant).toBe("acme") + expect(typeof cached.fingerprint).toBe("string") + expect(cached.email).toBeUndefined() + if (process.platform !== "win32") expect(statSync(cachePath).mode & 0o777).toBe(0o600) + + // Same credential: served from the cache, no request. + expect(await TelemetryIdentity.resolve()).toEqual({ user_id: UUID, tenant: "acme" }) + expect(await TelemetryIdentity.cached()).toEqual({ user_id: UUID, tenant: "acme" }) + expect(meCalls()).toHaveLength(1) + + // Forced refresh asks again. + await TelemetryIdentity.resolve({ refresh: true }) + expect(meCalls()).toHaveLength(2) + + // A different credential invalidates the cache. + writeCreds("key-2") + expect(await TelemetryIdentity.cached()).toBeUndefined() + expect(await TelemetryIdentity.resolve()).toEqual({ user_id: UUID, tenant: "acme" }) + expect(meCalls()).toHaveLength(3) + expect(meCalls()[2].headers["authorization"]).toBe("Bearer key-2") + }) + + test("resolves to nothing without a credential, on a failed request, or on a malformed answer", async () => { + expect(await TelemetryIdentity.resolve()).toBeUndefined() + expect(meCalls()).toHaveLength(0) + + writeCreds() + meStatus = 401 + expect(await TelemetryIdentity.resolve()).toBeUndefined() + expect(existsSync(TelemetryIdentity.cachePath())).toBe(false) + + meStatus = 200 + meBody = JSON.stringify({ id: 7, uuid: "not-a-uuid" }) + expect(await TelemetryIdentity.resolve()).toBeUndefined() + meBody = "proxy error" + expect(await TelemetryIdentity.resolve()).toBeUndefined() + expect(existsSync(TelemetryIdentity.cachePath())).toBe(false) + + meBody = undefined + await TelemetryIdentity.resolve() + expect(existsSync(TelemetryIdentity.cachePath())).toBe(true) + + // A rejection drops the cache for that credential; a transient failure keeps it. + meStatus = 503 + expect(await TelemetryIdentity.resolve({ refresh: true })).toBeUndefined() + expect(await TelemetryIdentity.cached()).toEqual({ user_id: UUID, tenant: "acme" }) + meStatus = 401 + expect(await TelemetryIdentity.resolve({ refresh: true })).toBeUndefined() + expect(await TelemetryIdentity.cached()).toBeUndefined() + expect(existsSync(TelemetryIdentity.cachePath())).toBe(false) + meStatus = 200 + // Signing out (credential gone) forgets the cached identity. + rmSync(path.join(home, ".altimate", "altimate.json")) + expect(await TelemetryIdentity.resolve()).toBeUndefined() + expect(existsSync(TelemetryIdentity.cachePath())).toBe(false) + }) + + test("an answer for a credential that was removed mid-flight is neither cached nor returned", async () => { + writeCreds("key-A") + let releaseA: () => void = () => {} + gates["key-A"] = new Promise((r) => (releaseA = r)) + const reached = new Promise((r) => (entered["key-A"] = r)) + const pending = TelemetryIdentity.resolve() + await reached // the request is in flight at the gate + rmSync(path.join(home, ".altimate", "altimate.json")) + releaseA() + expect(await pending).toBeUndefined() + expect(existsSync(TelemetryIdentity.cachePath())).toBe(false) + }) + + test("a key entered in the TUI (auth store) resolves like the credential file", async () => { + authSpy.mockResolvedValue({ type: "api", key: `${API}///::acme::key-S` }) + expect(await TelemetryIdentity.resolve()).toEqual({ user_id: UUID, tenant: "acme" }) + expect(calls.filter((c) => c.url.includes("/users/me")).at(-1)!.url).toBe(`${API}/users/me`) + expect(meCalls()).toHaveLength(1) + expect(await TelemetryIdentity.cached()).toEqual({ user_id: UUID, tenant: "acme" }) + // The credential file wins over the store when both exist. + writeCreds("key-F") + await TelemetryIdentity.resolve() + const last = calls.filter((c) => c.url.endsWith("/users/me")).at(-1)! + expect(last.headers["authorization"]).toBe("Bearer key-F") + }) + + test("a credential change on disk is noticed at the next flush, and a buffered batch keeps the identity it was tracked under", async () => { + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + writeCreds("key-A") + await Telemetry.shutdown() + await Telemetry.init() + await Telemetry.refreshIdentity() + expect(Telemetry.identityForTest()).toEqual({ user_id: UUID, tenant: "acme" }) + + Telemetry.track({ + type: "startup_ready", + session_id: "", + command: "a", + duration_ms: 1, + fresh_install: false, + timestamp: Date.now(), + }) + await new Promise((r) => setTimeout(r, 15)) // a distinct mtime for the rewritten file + writeCreds("key-B-longer-so-the-size-differs-too") + await Telemetry.flush() + const first = (JSON.parse(trackCalls().at(-1)!.body) as any[]).find( + (e) => e.data?.baseData?.name === "startup_ready", + ) + expect(first.data.baseData.properties.user_id).toBe(UUID) // the batch tracked under A stays A + const ok = await (async () => { + const until = Date.now() + 2000 + while (Date.now() < until) { + if (Telemetry.identityForTest()?.user_id === UUID_B) return true + await new Promise((r) => setTimeout(r, 20)) + } + return false + })() + expect(ok).toBe(true) + + Telemetry.track({ + type: "startup_ready", + session_id: "", + command: "b", + duration_ms: 1, + fresh_install: false, + timestamp: Date.now(), + }) + await Telemetry.flush() + const second = (JSON.parse(trackCalls().at(-1)!.body) as any[]).find( + (e) => e.data?.baseData?.properties?.command === "b", + ) + expect(second.data.baseData.properties.user_id).toBe(UUID_B) + + // Sign-out on disk: noticed the same way, and the account is gone from the next batch. + await new Promise((r) => setTimeout(r, 15)) + rmSync(path.join(home, ".altimate", "altimate.json")) + await Telemetry.flush() + await new Promise((r) => setTimeout(r, 50)) + expect(Telemetry.identityForTest()).toBeUndefined() + }) + + test("a transient failure at startup is retried at a later flush, not on every flush", async () => { + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + writeCreds() + meStatus = 503 + await Telemetry.shutdown() + await Telemetry.init() + await new Promise((r) => setTimeout(r, 60)) + expect(Telemetry.identityForTest()).toBeUndefined() + const after = meCalls().length + expect(after).toBeGreaterThanOrEqual(1) + await Telemetry.flush() + await Telemetry.flush() + await new Promise((r) => setTimeout(r, 30)) + expect(meCalls().length).toBe(after) // backoff: no retry within the window + + // A different credential appearing during the backoff is picked up at once. + meStatus = 200 + await new Promise((r) => setTimeout(r, 15)) + writeCreds("key-B-longer-so-the-size-differs-too") + await Telemetry.flush() + const gotB = await (async () => { + const until = Date.now() + 2000 + while (Date.now() < until) { + if (Telemetry.identityForTest()?.user_id === UUID_B) return true + await new Promise((r) => setTimeout(r, 20)) + } + return false + })() + expect(gotB).toBe(true) + + // And the unchanged credential is retried once the backoff has elapsed. + await new Promise((r) => setTimeout(r, 15)) + writeCreds("key-A") + meStatus = 503 + await Telemetry.flush() + await new Promise((r) => setTimeout(r, 60)) + expect(Telemetry.identityForTest()).toBeUndefined() + meStatus = 200 + Telemetry.retryIdentityNowForTest() + await Telemetry.flush() + const ok = await (async () => { + const until = Date.now() + 2000 + while (Date.now() < until) { + if (Telemetry.identityForTest()?.user_id === UUID) return true + await new Promise((r) => setTimeout(r, 20)) + } + return false + })() + expect(ok).toBe(true) + }) + + test("a resolution that started under the old credential never overwrites a newer sign-in", async () => { + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + writeCreds("key-A") + let releaseA: () => void = () => {} + gates["key-A"] = new Promise((r) => (releaseA = r)) + await Telemetry.shutdown() + await Telemetry.init() // kicks off the resolution for key-A, which is now held + writeCreds("key-B") + await Telemetry.refreshIdentity() + expect(Telemetry.identityForTest()).toEqual({ user_id: UUID_B, tenant: "acme" }) + releaseA() + await new Promise((r) => setTimeout(r, 50)) + expect(Telemetry.identityForTest()).toEqual({ user_id: UUID_B, tenant: "acme" }) + + // Likewise after shutdown: a resolution still in flight cannot repopulate the cleared state. + let releaseB: () => void = () => {} + gates["key-B"] = new Promise((r) => (releaseB = r)) + void Telemetry.refreshIdentity() + await Telemetry.shutdown() + releaseB() + await new Promise((r) => setTimeout(r, 50)) + expect(Telemetry.identityForTest()).toBeUndefined() + }) + + test("events carry user_id and tenant when signed in, and only the machine id otherwise", async () => { + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + writeCreds() + await Telemetry.shutdown() + await Telemetry.init() + await Telemetry.refreshIdentity() + expect(Telemetry.identityForTest()).toEqual({ user_id: UUID, tenant: "acme" }) + + Telemetry.track({ + type: "startup_ready", + session_id: "", + command: "test", + duration_ms: 1, + fresh_install: false, + timestamp: Date.now(), + }) + await Telemetry.flush() + expect(trackCalls().length).toBeGreaterThan(0) + const envelopes = JSON.parse(trackCalls().at(-1)!.body) as any[] + const env = envelopes.find((e) => e.data?.baseData?.name === "startup_ready") + expect(env).toBeDefined() + expect(env.data.baseData.properties.user_id).toBe(UUID) + expect(env.data.baseData.properties.tenant).toBe("acme") + expect(env.tags["ai.user.id"]).toBe(UUID) + const machineId = readFileSync(path.join(home, ".altimate", "machine-id"), "utf8").trim() + expect(env.data.baseData.properties.machine_id).toBe(machineId) + for (const value of Object.values(env.data.baseData.properties)) + expect(String(value)).not.toContain("example.invalid") + + // Sign out: the next events fall back to the machine id. + rmSync(path.join(home, ".altimate", "altimate.json")) + await Telemetry.refreshIdentity() + expect(Telemetry.identityForTest()).toBeUndefined() + calls = [] + Telemetry.track({ + type: "startup_ready", + session_id: "", + command: "test", + duration_ms: 1, + fresh_install: false, + timestamp: Date.now(), + }) + await Telemetry.flush() + const after = (JSON.parse(trackCalls().at(-1)!.body) as any[]).find( + (e) => e.data?.baseData?.name === "startup_ready", + ) + expect(after.data.baseData.properties.user_id).toBeUndefined() + expect(after.data.baseData.properties.tenant).toBeUndefined() + expect(after.tags["ai.user.id"]).toBe(machineId) + }) +}) diff --git a/packages/opencode/test/upstream/altimate-features.test.ts b/packages/opencode/test/upstream/altimate-features.test.ts index eb67869dc4..6f5fad55fc 100644 --- a/packages/opencode/test/upstream/altimate-features.test.ts +++ b/packages/opencode/test/upstream/altimate-features.test.ts @@ -408,10 +408,13 @@ describe("altimate features: telemetry fail-safe init", () => { expect(src).toContain("ALTIMATE_TELEMETRY_DISABLED") }) - test("telemetry/index.ts awaits Account.active() (now async after bridge)", async () => { + test("telemetry/index.ts no longer reads the account; identity comes from the stored credential", async () => { const src = await readSrc("altimate", "telemetry", "index.ts") - // bridge merge note: Account.active() became async in v1.4.0 - expect(src).toMatch(/await Account\.active\(\)/) + // The hashed account email left with the user-identity change: events carry the backend + // user uuid and tenant resolved by TelemetryIdentity, so there is no Account.active() caller + // here any more (it used to be awaited; Account.active() became async in v1.4.0). + expect(src).not.toMatch(/Account\.active\s*\(/) + expect(src).toMatch(/TelemetryIdentity\.resolve\(/) }) }) diff --git a/packages/opencode/test/upstream/bridge-merge-v3.test.ts b/packages/opencode/test/upstream/bridge-merge-v3.test.ts index 5cd201bc01..1b3bd32568 100644 --- a/packages/opencode/test/upstream/bridge-merge-v3.test.ts +++ b/packages/opencode/test/upstream/bridge-merge-v3.test.ts @@ -86,9 +86,11 @@ describe("bridge merge cycle 1: Account.active() is async — every caller await expect(content).toMatch(/accountSvc\.active\s*\(\s*\)/) }) - test("altimate/telemetry/index.ts awaits Account.active()", async () => { + test("altimate/telemetry/index.ts no longer calls Account.active()", async () => { + // Telemetry stopped reading the account when events gained user_id and tenant from the + // stored credential; the generic no-unawaited-call check below still covers any new caller. const content = await readText(path.join(srcDir, "altimate", "telemetry", "index.ts")) - expect(content).toMatch(/await\s+Account\.active\s*\(\s*\)/) + expect(content).not.toMatch(/Account\.active\s*\(/) }) test("no source file calls Account.active() without await or assignment", async () => {