/** * Usage telemetry collector — TypeScript port of the Tauri Rust collector that * used to live at `hq-workspace/apps/hq-sync/src-tauri/src/commands/telemetry.rs`. * * Why it moved: the Rust copy only ran inside the macOS menubar app. By moving * the logic into `@indigoai-us/hq-cloud`, every consumer of the package * (`hq-sync-runner`, `hq-cli`, mobile wrappers) emits telemetry uniformly. * * What it does: after each successful sync (`all-complete` arm of * `bin/sync-runner.ts`), walks Grok `updates.jsonl` sessions, live and archived * Codex rollouts, and Claude session logs, then diffs each file against the * persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`, sanitizes new * rows through a tight allowlist that matches the server's KEEP_FIELDS set in * `apps/hq-pro/src/vault-service/handlers/usage.ts`, batches into server-sized * POST bodies, and ships them to `/v1/usage`. * * Trust model: the caller's `personUid` is resolved on the server from the * Cognito JWT — never from the body. `sanitizeRow` strips prompt bodies, * thinking content, tool inputs/outputs, and any nested `message` object so * the wire payload contains only token-accounting fields. * * Errors are swallowed by design — telemetry must never abort or delay a * sync. The cursor is only advanced for batches the server 2xx'd, so a * transient outage retries automatically on the next sync. */ import type { TelemetryOptInResponse, UsageBatch, UsageIngestResult } from "./vault-client.js"; /** * Minimal subset of `VaultClient` the collector needs. Declared as an * interface so tests can inject a stub without spinning up a fetch mock. * The real `VaultClient` from `./vault-client.js` satisfies this structurally. */ export interface TelemetryClientSurface { getTelemetryOptIn(): Promise; postUsage(batch: UsageBatch): Promise; /** * Optional so an older client (or a narrow test stub) still satisfies the * surface — when it is absent the consent self-heal is simply skipped. */ setTelemetryOptIn?(enabled: boolean, opts?: { onlyIfUnset?: boolean; }): Promise<{ applied: boolean; } | void>; } export interface CollectTelemetryOptions { client: TelemetryClientSurface; /** Stable per-machine id. The Tauri menubar reads this from `~/.hq/menubar.json`; the runner can pass it through or generate one once and cache. */ machineId: string; /** Version of the wrapping caller (menubar app, CLI, etc.). Reaches CloudWatch metrics as the `installerVersion` dimension. */ installerVersion: string; /** * HQ root, used to resolve each event's `cwd` → owning repo → owning company * via `/companies/manifest.yaml` and stamp `companyUid` on the event * (surface-hq-console-telemetry US-002). The manifest is parsed ONCE per run * (see `buildRepoCompanyMap`); the resulting map is reused for every event. * When omitted (or when no repo matches), `companyUid` is left UNSET and the * server treats the event as unattributed/personal. */ hqRoot?: string; /** * Explicit single-company sync scope (`--company `). Used only * when an event cwd does not resolve through the manifest. This is intentionally * absent for multi-company and personal runs to prevent cross-tenant attribution. */ fallbackCompany?: string; /** Override `~/.claude/projects` for tests. */ claudeProjectsRoot?: string; /** Override the home directory used for automatic Claude profile discovery. */ homeDir?: string; /** Override `~/.codex` for tests. Both live and archived rollouts are scanned. */ codexRoot?: string; /** Override `~/.grok` for tests. Sessions under `{grokRoot}/sessions/.../updates.jsonl`. */ grokRoot?: string; /** Override the per-runtime scan budget for deterministic tests. */ maxScanBytesPerSource?: number; /** Override the upload batch cap for deterministic tests. */ maxBatchesPerRun?: number; /** Override `~/.hq/telemetry-cursor.json` for tests. */ cursorPath?: string; /** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */ menubarPath?: string; /** * Fleet agents have no consent dialog. When true, collection is on for * company-attributed AND unattributed/personal rows — the personal opt-in * check is skipped (including menubar fallback). */ forceCollect?: boolean; /** Diagnostic sink. No-op by default. */ log?: (msg: string) => void; } export interface CollectTelemetryResult { /** Whether the opt-in check resolved to true (either server-side or via the menubar fallback). When false, at most company-scoped rows shipped (see `companyScopeOnly`). */ enabled: boolean; /** * True when the personal opt-in resolved FALSE but the run still scanned and * shipped rows whose cwd resolved to a cloud-backed company (or the * configured `fallbackCompany`). Personal consent governs personal / * unattributed work only; work inside a company's folders is the company's * and is captured regardless — mirroring the read side's "opted-OUT member * is STILL counted" invariant (hq-pro `handlers/telemetry.ts`). The server's * per-company `telemetryCollectionEnabled` gate still applies at ingest. */ companyScopeOnly?: boolean; /** Source for the `enabled` decision — useful for diagnosing missing-events reports. */ optInSource: "server" | "menubar-fallback" | "menubar-reasserted" | "agent-required" | "skipped"; /** How many `.jsonl` files we considered (before the cursor diff). */ filesScanned: number; /** Total events successfully POSTed across all batches. */ eventsSent: number; /** Number of `POST /v1/usage` requests made. */ batchesSent: number; } /** Per-file Grok session metadata loaded once from sibling `summary.json`. */ export interface GrokSessionMeta { cwd?: string; gitBranch?: string; fallback_model?: string; } /** * SECURITY-CRITICAL (owner-telemetry-v2 US-007). Extract the MCP servers / * connectors / integrations a session row touched as a NAMES-AND-COUNTS-ONLY map * `{ : }` — e.g. `{ slack: 2, figma: 1 }`. * * This mirrors the `hasArgs` redaction precedent in skill-telemetry.ts: we look * ONLY at the tool-use block's `name` (to recover the server segment of * `mcp____`) and count occurrences. We NEVER read the block's * `input` (which carries the args/URLs/payloads), never emit the tool name, and * never emit any argument text. So no args, URLs, payloads, or secrets can reach * the wire — only a service name and how many times it was invoked in this row. * * Returns `undefined` when the row touched no MCP server, so the caller omits the * `services` field entirely (backward-compatible: old-shaped rows carry nothing * new, and the server treats absence as "no services"). */ export declare function extractServices(message: unknown): Record | undefined; /** * Build an outgoing event row matching the server's KEEP allowlist. * * Two transforms: * 1. Top-level fields are copied straight through (string identity). * 2. `message.model` and `message.usage.{input_tokens, output_tokens, * cache_creation_input_tokens, cache_read_input_tokens}` are promoted to * camelCase top-level fields. The original `message` object — which * carries prompt/response text, thinking, and tool data — is dropped. * * `services` (US-007): the MCP servers / connectors / integrations this row * touched, as a names-and-counts-only map (see `extractServices`). NAMES + COUNTS * ONLY — never args/URLs/payloads/secrets, matching the `hasArgs` precedent. * Omitted when the row touched no MCP server (the server treats absence as none). * * `companyUid` (US-002): when the caller has resolved the row's `cwd` to an * owning company (`resolveCompanyForCwd`), it passes that `cmp_*` uid here and * it is stamped on the wire row. It is on the server's KEEP allowlist * (`apps/hq-pro/src/vault-service/handlers/usage.ts`). When `companyUid` is * undefined (cwd maps to no company repo) the field is OMITTED — the server * treats absence as unattributed/personal. The reserved value `unattributed` * is never produced (it can only come from a resolved manifest `cmp_*` uid). * * Returns `null` when the input isn't an object. Empty results (e.g. a row * with no recognised fields) are still returned as `{}` and emitted; the * server accepts empty rows and they're useful as a "Claude Code was run at * this time" heartbeat. */ export declare function sanitizeRow(row: unknown, companyUid?: string): Record | null; /** * Adapt a Grok `updates.jsonl` record to Claude-shaped sanitizer input(s). * Emits only on `turn_completed` with `usage`. When `usage.modelUsage` is * present, one row per model key (chart stacks by model id). */ export declare function grokUsageRows(row: unknown, sessionMeta: GrokSessionMeta): Array>; /** Load cwd / branch / fallback model from sibling `summary.json` once per file. */ export declare function loadGrokSessionMeta(updatesPath: string): Promise; /** * Scan, sanitize, and POST new Grok, Codex, and Claude Code usage rows. * * Fire-and-forget from the caller's perspective: errors are caught internally * and surfaced only via `log`. The returned summary lets observers (e.g. * sync-runner) decide whether to record a "telemetry attempted" breadcrumb, * but no consumer is expected to react to it. */ export declare function collectAndSendTelemetry(opts: CollectTelemetryOptions): Promise; //# sourceMappingURL=telemetry.d.ts.map