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
10 changes: 10 additions & 0 deletions packages/opencode/src/altimate/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string> {
return { "x-altimate-client": `altimate-code/${InstallationVersion}` }
}

export async function isConfigured(): Promise<boolean> {
return Filesystem.exists(credentialsPath())
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -224,6 +232,7 @@ export namespace AltimateApi {
headers: {
"x-tenant": instance,
"Content-Type": "application/json",
...clientHeaders(),
},
body: JSON.stringify({ token }),
signal: controller.signal,
Expand Down Expand Up @@ -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) } : {}),
})
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/altimate/plugin/altimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -491,6 +492,10 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise<Hooks> {
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(() => {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This refresh updates only the auth worker's telemetry state; the TUI main thread keeps the anonymous identity resolved before sign-in. Refresh the main-thread identity too, such as through the TUI RPC, so its later events carry the signed-in user and tenant.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/altimate/plugin/altimate.ts, line 497:

<comment>This refresh updates only the auth worker's telemetry state; the TUI main thread keeps the anonymous identity resolved before sign-in. Refresh the main-thread identity too, such as through the TUI RPC, so its later events carry the signed-in user and tenant.</comment>

<file context>
@@ -491,6 +492,10 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise<Hooks> {
                   })
+                  // 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
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d275dec without an RPC: each thread's own flush notices the credential files changed (two stats) and re-resolves; the main thread reads the cache the worker's sign-in wrote, so no second request. The explicit refreshIdentity() after the browser sign-in stays for immediacy on the worker.

// 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
Expand Down
197 changes: 197 additions & 0 deletions packages/opencode/src/altimate/telemetry/identity.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return (await loadCredential()) !== undefined
}

/** The credential the Altimate provider would use, or undefined when signed out. */
export async function loadCredential(): Promise<Credential | undefined> {
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<Cached | undefined> {
try {
const p = cachePath()
if (!(await Filesystem.exists(p))) return undefined
const raw = JSON.parse(await Filesystem.readText(p)) as Partial<Cached>
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<Identity | undefined> {
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<Identity | undefined> {
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)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
} 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<void> {
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<Answer> {
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)
}
}
}
Loading
Loading