diff --git a/.opencode/skills/data-viz/references/component-guide.md b/.opencode/skills/data-viz/references/component-guide.md index 28a6ce46d8..4e27168380 100644 --- a/.opencode/skills/data-viz/references/component-guide.md +++ b/.opencode/skills/data-viz/references/component-guide.md @@ -422,7 +422,7 @@ activateTab('overview'); // init the default visible tab on page load Library-specific notes: - **Chart.js**: canvas reads as `0×0` inside `display:none` — bars/lines never appear - **Recharts `ResponsiveContainer`**: reads `clientWidth = 0` — chart collapses to nothing -- **Nivo `Responsive*`**: uses `ResizeObserver` — fires once at `0×0`, never re-fires on show +- **Nivo `Responsive*`**: uses `ResizeObserver` via `useMeasure`/`useDimensions` in `@nivo/core` — initially measures `0×0` when hidden and skips rendering; re-measures and re-renders correctly when container becomes visible, but the initial blank frame can cause a flash - **React conditional rendering**: prefer `visibility:hidden` + `position:absolute` over toggling `display:none` if you want charts to stay mounted and pre-rendered --- diff --git a/docs/docs/reference/security-faq.md b/docs/docs/reference/security-faq.md index ce5c8ff4e7..71fb872318 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -126,6 +126,23 @@ Or via environment variable: export ALTIMATE_TELEMETRY_DISABLED=true ``` +### How does Altimate Code identify users for analytics? + +- **Logged-in users:** Your email is SHA-256 hashed before sending. We never see your raw email. +- **Anonymous users:** A random UUID (`crypto.randomUUID()`) is generated on first run and stored at `~/.altimate/machine-id`. This is NOT tied to your hardware, OS, or identity — it's purely random. +- **Both identifiers** are only sent when telemetry is enabled. Disable with `ALTIMATE_TELEMETRY_DISABLED=true`. +- **No fingerprinting:** We do not use browser fingerprinting, hardware IDs, MAC addresses, or IP-based tracking. + +### What happens on first launch? + +A single `first_launch` event is sent containing only: + +- The installed version (e.g., "0.5.9") +- Whether this is a fresh install or upgrade (boolean) +- Your anonymous machine ID (random UUID) + +No code, queries, file paths, or personal information is included. This event helps us understand adoption and is fully opt-out-able. + ## What happens when I authenticate via a well-known URL? When you run `altimate auth login `, the CLI fetches `/.well-known/altimate-code` to discover the server's auth command. Before executing anything: diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index 4535ae72b6..e5e8a146ef 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -36,6 +36,7 @@ We collect the following categories of events: | `skill_used` | A skill is loaded (skill name and source — `builtin`, `global`, or `project` — no skill content) | | `sql_execute_failure` | A SQL execution fails (warehouse type, query type, error message, PII-masked SQL — no raw values) | | `core_failure` | An internal tool error occurs (tool name, category, error class, truncated error message, PII-safe input signature, and optionally masked arguments — no raw values or credentials) | +| `first_launch` | Fired once on first CLI run after installation. Contains version and is_upgrade flag. No PII. | Each event includes a timestamp, anonymous session ID, CLI version, and an anonymous machine ID (a random UUID stored in `~/.altimate/machine-id`, generated once and never tied to any personal information). @@ -88,6 +89,19 @@ We take your privacy seriously. Altimate Code telemetry **never** collects: Error messages are truncated to 500 characters and scrubbed of file paths before sending. +### New User Identification + +Altimate Code uses two types of anonymous identifiers for analytics, depending on whether you are logged in: + +- **Anonymous users (not logged in):** A random UUID is generated using `crypto.randomUUID()` on first run and stored at `~/.altimate/machine-id`. This ID is not tied to your hardware, operating system, or identity — it is purely random and serves only to distinguish one machine from another in aggregate analytics. +- **Logged-in users (OAuth):** Your email address is SHA-256 hashed before sending. The raw email is never transmitted. + +Both identifiers are only sent when telemetry is enabled. Disable telemetry entirely with `ALTIMATE_TELEMETRY_DISABLED=true` or the config option above. + +### Data Retention + +Telemetry data is sent to Azure Application Insights and retained according to [Microsoft's data retention policies](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/data-retention-configure). We do not maintain a separate data store. To request deletion of your telemetry data, contact privacy@altimate.ai. + ## Network Telemetry data is sent to Azure Application Insights: diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 25ae604150..659cf70d88 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -350,6 +350,15 @@ export namespace Telemetry { skill_source: "builtin" | "global" | "project" duration_ms: number } + // altimate_change start — first_launch event for new user counting (privacy-safe: only version + machine_id) + | { + type: "first_launch" + timestamp: number + session_id: string + version: string + is_upgrade: boolean + } + // altimate_change end // altimate_change start — telemetry for skill management operations | { type: "skill_created" @@ -618,7 +627,13 @@ export namespace Telemetry { iKey: cfg.iKey, tags: { "ai.session.id": sid || "startup", - "ai.user.id": userEmail, + // 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 end "ai.cloud.role": "altimate", "ai.application.ver": Installation.VERSION, }, diff --git a/packages/opencode/src/cli/cmd/tui/component/tips.tsx b/packages/opencode/src/cli/cmd/tui/component/tips.tsx index d3b9d9cc09..818edfed01 100644 --- a/packages/opencode/src/cli/cmd/tui/component/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/tips.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, For } from "solid-js" +import { createMemo, For } from "solid-js" import { DEFAULT_THEMES, useTheme } from "@tui/context/theme" const themeCount = Object.keys(DEFAULT_THEMES).length @@ -47,12 +47,16 @@ const BEGINNER_TIPS = [ ] // altimate_change end -// altimate_change start — first-time user beginner tips +// altimate_change start — first-time user beginner tips with reactive pool export function Tips(props: { isFirstTime?: boolean }) { const theme = useTheme().theme - const pool = props.isFirstTime ? BEGINNER_TIPS : TIPS - const parts = parse(pool[Math.floor(Math.random() * pool.length)]) - // altimate_change end + // Pick random tip index once on mount instead of recalculating randomly when props change + // Use useMemo without dependencies so it only evaluates once + const tipIndex = Math.random() + const tip = createMemo(() => { + const pool = props.isFirstTime ? BEGINNER_TIPS : TIPS + return parse(pool[Math.floor(tipIndex * pool.length)]) + }) return ( @@ -60,13 +64,14 @@ export function Tips(props: { isFirstTime?: boolean }) { ● Tip{" "} - + {(part) => {part.text}} ) } +// altimate_change end const TIPS = [ "Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files", diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index d16bc5d15a..a702e3af25 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -38,7 +38,14 @@ export function Home() { return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length }) - const isFirstTimeUser = createMemo(() => sync.data.session.length === 0) + // altimate_change start — fix race condition: don't show beginner UI until sessions loaded + const isFirstTimeUser = createMemo(() => { + // Don't evaluate until sessions have actually loaded (avoid flash of beginner UI) + // Return undefined to represent "loading" state + if (sync.status === "loading" || sync.status === "partial") return undefined + return sync.data.session.length === 0 + }) + // altimate_change end const tipsHidden = createMemo(() => kv.get("tips_hidden", false)) const showTips = createMemo(() => { // Always show tips — first-time users need guidance the most @@ -127,7 +134,7 @@ export function Home() { /> {/* altimate_change start — first-time onboarding hint */} - + Get started: @@ -146,7 +153,7 @@ export function Home() { {/* altimate_change start — pass first-time flag for beginner tips */} - + {/* altimate_change end */} diff --git a/packages/opencode/src/cli/welcome.ts b/packages/opencode/src/cli/welcome.ts index f01e7b9759..2b08a79b35 100644 --- a/packages/opencode/src/cli/welcome.ts +++ b/packages/opencode/src/cli/welcome.ts @@ -3,6 +3,9 @@ import path from "path" import os from "os" import { Installation } from "../installation" import { EOL } from "os" +// altimate_change start — import Telemetry for first_launch event +import { Telemetry } from "../altimate/telemetry" +// altimate_change end const APP_NAME = "altimate-code" const MARKER_FILE = ".installed-version" @@ -36,10 +39,23 @@ export function showWelcomeBannerIfNeeded(): void { // Remove marker first to avoid showing twice even if display fails fs.unlinkSync(markerPath) - // altimate_change start — VERSION is already normalized (no "v" prefix) - const currentVersion = Installation.VERSION + // altimate_change start — use ~/.altimate/machine-id existence as a proxy for upgrade vs fresh install + // Since postinstall.mjs always writes the current version to the marker file, we can't reliably + // use installedVersion !== currentVersion for release builds. Instead, if machine-id exists, + // they've run the CLI before. + const machineIdPath = path.join(os.homedir(), ".altimate", "machine-id") + const isUpgrade = fs.existsSync(machineIdPath) + // altimate_change end + + // altimate_change start — track first launch for new user counting (privacy-safe: only version + machine_id) + Telemetry.track({ + type: "first_launch", + timestamp: Date.now(), + session_id: "", + version: installedVersion, + is_upgrade: isUpgrade, + }) // altimate_change end - const isUpgrade = installedVersion === currentVersion && installedVersion !== "local" if (!isUpgrade) return @@ -51,7 +67,9 @@ export function showWelcomeBannerIfNeeded(): void { const reset = "\x1b[0m" const bold = "\x1b[1m" - const v = `altimate-code v${currentVersion} installed` + // altimate_change start — use installedVersion (from marker) instead of currentVersion for accurate banner + const v = `altimate-code v${installedVersion} installed` + // altimate_change end const lines = [ "", " Get started:", diff --git a/packages/opencode/test/telemetry/telemetry.test.ts b/packages/opencode/test/telemetry/telemetry.test.ts index b8f4b1fb1a..f1b7990eb9 100644 --- a/packages/opencode/test/telemetry/telemetry.test.ts +++ b/packages/opencode/test/telemetry/telemetry.test.ts @@ -231,8 +231,12 @@ describe("telemetry.event-types", () => { "warehouse_discovery", "warehouse_census", "core_failure", + "first_launch", + "skill_created", + "skill_installed", + "skill_removed", ] - expect(eventTypes.length).toBe(33) + expect(eventTypes.length).toBe(37) }) }) @@ -352,6 +356,10 @@ describe("telemetry.naming-convention", () => { "warehouse_discovery", "warehouse_census", "core_failure", + "first_launch", + "skill_created", + "skill_installed", + "skill_removed", ] for (const t of types) { expect(t).toMatch(/^[a-z][a-z0-9_]*$/) @@ -418,8 +426,7 @@ describe("telemetry.parseConnectionString (indirect)", () => { try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "IngestionEndpoint=https://example.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "IngestionEndpoint=https://example.com" await Telemetry.init() Telemetry.track({ @@ -734,8 +741,7 @@ describe("telemetry.flush", () => { try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "InstrumentationKey=k;IngestionEndpoint=https://e.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=k;IngestionEndpoint=https://e.com" await Telemetry.init() Telemetry.track({ @@ -770,8 +776,7 @@ describe("telemetry.flush", () => { try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "InstrumentationKey=k;IngestionEndpoint=https://e.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=k;IngestionEndpoint=https://e.com" await Telemetry.init() Telemetry.track({ @@ -810,8 +815,7 @@ describe("telemetry.flush", () => { try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "InstrumentationKey=k;IngestionEndpoint=https://e.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=k;IngestionEndpoint=https://e.com" await Telemetry.init() Telemetry.track({ @@ -858,8 +862,7 @@ describe("telemetry.flush", () => { try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "InstrumentationKey=k;IngestionEndpoint=https://e.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=k;IngestionEndpoint=https://e.com" await Telemetry.init() Telemetry.track({ @@ -894,8 +897,7 @@ describe("telemetry.flush", () => { try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "InstrumentationKey=k;IngestionEndpoint=https://e.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=k;IngestionEndpoint=https://e.com" await Telemetry.init() // Fill buffer beyond MAX_BUFFER_SIZE (200) to trigger drops @@ -917,8 +919,8 @@ describe("telemetry.flush", () => { const envelopes = JSON.parse(fetchBodies[0]) // Should include a TelemetryBufferOverflow error event const overflowEvent = envelopes.find( - (e: any) => e.data?.baseData?.name === "error" && - e.data?.baseData?.properties?.error_name === "TelemetryBufferOverflow", + (e: any) => + e.data?.baseData?.name === "error" && e.data?.baseData?.properties?.error_name === "TelemetryBufferOverflow", ) expect(overflowEvent).toBeDefined() expect(overflowEvent.data.baseData.properties.error_message).toContain("10 events dropped") @@ -970,8 +972,7 @@ describe("telemetry.shutdown", () => { try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "InstrumentationKey=k;IngestionEndpoint=https://e.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=k;IngestionEndpoint=https://e.com" await Telemetry.init() Telemetry.track({ @@ -1005,8 +1006,7 @@ describe("telemetry.shutdown", () => { try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "InstrumentationKey=k;IngestionEndpoint=https://e.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=k;IngestionEndpoint=https://e.com" await Telemetry.init() Telemetry.setContext({ sessionId: "sess-1", projectId: "proj-1" }) @@ -1060,8 +1060,7 @@ describe("telemetry.shutdown", () => { try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "InstrumentationKey=k;IngestionEndpoint=https://e.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=k;IngestionEndpoint=https://e.com" await Telemetry.init() await Telemetry.shutdown() @@ -1101,8 +1100,7 @@ describe("telemetry.buffer overflow", () => { try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "InstrumentationKey=k;IngestionEndpoint=https://e.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=k;IngestionEndpoint=https://e.com" await Telemetry.init() // Track 250 events — first 50 should be dropped @@ -1158,8 +1156,7 @@ describe("telemetry.buffer overflow", () => { try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "InstrumentationKey=k;IngestionEndpoint=https://e.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=k;IngestionEndpoint=https://e.com" await Telemetry.init() // Exactly 205 events — 5 should be dropped @@ -1211,8 +1208,7 @@ describe("telemetry.init with enabled telemetry", () => { try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "InstrumentationKey=k;IngestionEndpoint=https://e.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=k;IngestionEndpoint=https://e.com" await Telemetry.init() // If flush timer is set up, tracking + waiting should eventually trigger flush @@ -1316,8 +1312,7 @@ describe("telemetry.init with enabled telemetry", () => { try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "InstrumentationKey=k;IngestionEndpoint=https://e.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=k;IngestionEndpoint=https://e.com" const p1 = Telemetry.init() const p2 = Telemetry.init() @@ -1450,6 +1445,18 @@ describe("telemetry.memory", () => { }) }).not.toThrow() }) + + test("track accepts first_launch event without throwing", () => { + expect(() => { + Telemetry.track({ + type: "first_launch", + timestamp: Date.now(), + session_id: "", + version: "0.5.9", + is_upgrade: false, + }) + }).not.toThrow() + }) }) // --------------------------------------------------------------------------- @@ -1483,8 +1490,7 @@ describe("Telemetry.isEnabled()", () => { spyOn(global, "fetch").mockImplementation(async () => new Response("", { status: 200 })) try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "InstrumentationKey=k;IngestionEndpoint=https://e.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=k;IngestionEndpoint=https://e.com" await Telemetry.init() expect(Telemetry.isEnabled()).toBe(true) } finally { @@ -1501,8 +1507,7 @@ describe("Telemetry.isEnabled()", () => { spyOn(global, "fetch").mockImplementation(async () => new Response("", { status: 200 })) try { delete process.env.ALTIMATE_TELEMETRY_DISABLED - process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = - "InstrumentationKey=k;IngestionEndpoint=https://e.com" + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=k;IngestionEndpoint=https://e.com" await Telemetry.init() expect(Telemetry.isEnabled()).toBe(true) await Telemetry.shutdown()