Files
orca/src/main/telemetry/client.ts
T
NeilandOrca 98b00d3a64 refactor(comments): slim verbose comments in main core (runtime/ipc/daemon/pty) (#9542)
Collapse multi-line explanatory comment blocks into single-line "why" statements
per AGENTS.md ("Document the Why, Briefly"): drop restatements of the code and
mechanism narration; keep the non-obvious reason, external refs, and directives.

Comments-only — verified no code changed via a Babel/esbuild comment-strip
token-equality gate against origin/main; typecheck and oxlint clean.

Area: main — core runtime, ipc, daemon, pty, providers. 73 files changed, 3206 insertions(+), 10475 deletions(-).

Co-authored-by: Orca <help@stably.ai>
2026-07-20 03:18:24 -07:00

343 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Main-process telemetry transport: one posthog-node client, one `track()` entry that every
// event (main + IPC) funnels through. The ordering inside `track()` — shutdown gate, burst cap,
// consent, validator, capture — MUST be preserved: burst cap runs before consent so a compromised
// opted-out renderer can't force a settings read per event.
// `$process_person_profile: false` is attached per capture because posthog-node has no init-time
// equivalent of posthog-js's `person_profiles: 'identified_only'` (no PostHog person per install_id).
import { randomUUID } from 'node:crypto'
import { arch as osArch, platform as osPlatform, release as osRelease } from 'node:os'
import { app } from 'electron'
import { PostHog } from 'posthog-node'
import type { CommonProps, EventName, EventProps, OptInVia } from '../../shared/telemetry-events'
import type { Store } from '../persistence'
import { consumeBurstToken, resetBurstCapsForSession } from './burst-cap'
import { getCohortAtEmit } from './cohort-classifier'
import { resolveConsent, type ConsentState } from './consent'
import { commonPropsSchema, validate } from './validator'
// Compile-time feature flag, independent of the build-identity gate — both must be satisfied to transmit.
// NOTE: config/scripts/verify-telemetry-constants.mjs greps `const TELEMETRY_ENABLED = true|false`; keep that shape or update its regex.
const TELEMETRY_ENABLED = true
// Eligible to transmit only if CI injected BOTH build-identity and write key; either alone fails closed, with no runtime env-var override (dev/contributor builds get `null`).
// The `globalThis` reads are for vitest, which skips electron-vite's `define` pass — resolving to `IS_OFFICIAL_BUILD === false` there.
const BUILD_IDENTITY: 'stable' | 'rc' | null =
typeof ORCA_BUILD_IDENTITY !== 'undefined'
? ORCA_BUILD_IDENTITY
: ((globalThis as { ORCA_BUILD_IDENTITY?: 'stable' | 'rc' | null }).ORCA_BUILD_IDENTITY ?? null)
const WRITE_KEY: string | null =
typeof ORCA_POSTHOG_WRITE_KEY !== 'undefined'
? ORCA_POSTHOG_WRITE_KEY
: ((globalThis as { ORCA_POSTHOG_WRITE_KEY?: string | null }).ORCA_POSTHOG_WRITE_KEY ?? null)
const IS_OFFICIAL_BUILD: boolean =
(BUILD_IDENTITY === 'stable' || BUILD_IDENTITY === 'rc') &&
typeof WRITE_KEY === 'string' &&
WRITE_KEY.length > 0
// Module-level singletons — one Store / process / telemetry session; threading `store` everywhere buys nothing.
let posthog: PostHog | null = null
let sessionId: string | null = null
let commonProps: CommonProps | null = null
let shuttingDown = false
let storeRef: Store | null = null
const OPT_OUT_CAPTURE_ENQUEUE_TIMEOUT_MS = 1_000
// Test-only transport-gate override (`_enableTransportForTests`) so tests exercise the full pipeline without a real CI build.
let testTransportEnabled = false
// First-launch `app_opened` gate: no events transmit until the banner resolves; keep mark+emit atomic.
let appOpenedTrackedThisSession = false
function buildCommonProps(installId: string, sid: string, channel: 'stable' | 'rc'): CommonProps {
// Don't truncate here; the validator's `.max(64)` is authoritative, so an over-long string drops rather than being silently masked.
return {
app_version: app.getVersion(),
platform: osPlatform(),
arch: osArch(),
os_release: osRelease(),
install_id: installId,
session_id: sid,
orca_channel: channel
}
}
export function initTelemetry(store: Store): void {
// Set unconditionally so `setOptIn` can persist opt-out to disk even on contributor / non-official builds.
storeRef = store
resetBurstCapsForSession()
shuttingDown = false
// Reset per session: the "no app_opened until banner resolution" invariant is per-launch, not per-install.
appOpenedTrackedThisSession = false
if (!TELEMETRY_ENABLED || !IS_OFFICIAL_BUILD) {
return
}
const settings = store.getSettings()
const installId = settings.telemetry?.installId
if (!installId) {
// Migration guarantees installId; if missing, don't transmit with an absent distinct_id.
console.warn('[telemetry] installId missing after migration; skipping transport init')
return
}
sessionId = randomUUID()
commonProps = buildCommonProps(
installId,
sessionId,
// Non-null here: `IS_OFFICIAL_BUILD` gated this branch to the `'stable' | 'rc'` arm.
BUILD_IDENTITY as 'stable' | 'rc'
)
// Fail-closed: a bad `install_id` (e.g. empty from a migration bug) would collapse all events into one distinct_id.
// Validated once here (not per `track()`): `commonProps` is a session-lifetime singleton that can't drift.
const parsedCommon = commonPropsSchema.safeParse(commonProps)
if (!parsedCommon.success) {
console.warn('[telemetry] common props failed schema validation; skipping transport init')
commonProps = null
return
}
posthog = new PostHog(WRITE_KEY as string, {
host: 'https://us.i.posthog.com',
flushAt: 20,
flushInterval: 10_000,
// Strip SDK-auto GeoIP / client-IP enrichment; our wire is exactly CommonProps ∪ EventProps ∪ a small allow-list.
disableGeoip: true,
// Bumped from the default 1000 (drops oldest-first past cap) to 5000 to tolerate long-offline sessions.
maxQueueSize: 5000
})
if (shouldOptOutSdkAtInit(resolveConsent(settings))) {
posthog.optOut()
}
}
/**
* Whether to flip the PostHog SDK's in-memory `optedOut` flag at boot.
*
* True for DISABLED cohorts only, re-applied every boot (the flag doesn't persist) so any direct
* `posthog.capture()` bypassing `track()` still drops for an opted-out user. Deliberately excludes
* `pending_banner`: the direct `telemetry_opted_out` capture in `setOptIn(_, false)` must not drop,
* or we'd lose the one signal that the opt-out flow works.
*/
export function shouldOptOutSdkAtInit(consent: ConsentState): boolean {
return consent.effective === 'disabled'
}
function waitForCaptureEnqueue(client: PostHog, event: EventName, uuid: string): Promise<boolean> {
return new Promise((resolve) => {
let settled = false
let stopListening: (() => void) | null = null
let timeout: ReturnType<typeof setTimeout> | null = null
const settle = (enqueued: boolean): void => {
if (settled) {
return
}
settled = true
if (timeout) {
clearTimeout(timeout)
}
stopListening?.()
resolve(enqueued)
}
// Why: posthog-node capture() enqueues async; this SDK event is the durable boundary before optOut().
stopListening = client.on('capture', (payload: unknown) => {
if (!payload || typeof payload !== 'object') {
return
}
const message = payload as { event?: unknown; uuid?: unknown }
if (message.event === event && message.uuid === uuid) {
settle(true)
}
})
timeout = setTimeout(() => settle(false), OPT_OUT_CAPTURE_ENQUEUE_TIMEOUT_MS)
})
}
// No-op in contributor / non-official builds; only official stable/rc builds (CI-injected `ORCA_BUILD_IDENTITY` + `ORCA_POSTHOG_WRITE_KEY`) transmit.
export function track<N extends EventName>(name: N, props: EventProps<N>): void {
if (!testTransportEnabled && (!IS_OFFICIAL_BUILD || !TELEMETRY_ENABLED)) {
return
}
// (1) Shutdown gate: late IPC arrivals must not enqueue against a flushing client.
if (shuttingDown) {
return
}
if (!posthog || !commonProps || !storeRef) {
return
}
// (2) Burst cap before consent: the O(1) cap drops floods before the costly settings read, so a compromised opted-out renderer can't burn CPU.
if (!consumeBurstToken(name)) {
return
}
// (3) Consent resolve — reads live settings every call so it can't drift from persisted state / env-var precedence.
const consent = resolveConsent(storeRef.getSettings())
if (consent.effective !== 'enabled') {
return
}
// (4) Validator — single enforcement point for schema, enum, key set, and length caps.
const result = validate(name, props)
if (!result.ok) {
return
}
// (5) Capture. `$process_person_profile: false` stops posthog-node creating a person per install_id (no init-time equivalent).
posthog.capture({
distinctId: commonProps.install_id,
event: name,
properties: {
...commonProps,
...result.props,
$process_person_profile: false
}
})
}
export async function setOptIn(via: OptInVia, optedIn: boolean): Promise<void> {
if (!storeRef) {
return
}
const settings = storeRef.getSettings()
const telemetryBeforeUpdate = settings.telemetry
const wasPendingBanner =
telemetryBeforeUpdate?.existedBeforeTelemetryRelease === true &&
telemetryBeforeUpdate.optedIn === null
// Deep-merge (persistence.ts:552) so flipping `optedIn` won't clobber `installId` / `existedBeforeTelemetryRelease`.
storeRef.updateSettings({
telemetry: {
...(settings.telemetry ?? { installId: '', existedBeforeTelemetryRelease: true }),
optedIn
}
})
const client = posthog
if (optedIn) {
if (client) {
await client.optIn()
}
if (wasPendingBanner) {
trackAppOpenedOnce()
}
track('telemetry_opted_in', { via })
} else {
if (!client) {
return
}
// Fire before disabling the SDK — the one event that must transmit against the new preference. Capture directly (not
// `track()`, which would drop it on `user_opt_out`); await enqueue since posthog-node captures async and must confirm before optOut().
try {
if (!shuttingDown && commonProps && consumeBurstToken('telemetry_opted_out')) {
const validated = validate('telemetry_opted_out', { via })
if (validated.ok) {
const uuid = randomUUID()
const enqueued = waitForCaptureEnqueue(client, 'telemetry_opted_out', uuid)
client.capture({
distinctId: commonProps.install_id,
event: 'telemetry_opted_out',
uuid,
properties: {
...commonProps,
...validated.props,
$process_person_profile: false
}
})
if (!(await enqueued)) {
console.warn('[telemetry] telemetry_opted_out did not enqueue before SDK opt-out')
}
}
}
} catch (err) {
console.warn('[telemetry] telemetry_opted_out capture failed before SDK opt-out:', err)
} finally {
await client.optOut()
}
}
}
// Banner ✕: silent persisted opt-in. Separate from `setOptIn` because that always emits a
// `telemetry_opted_in/out` event; here `app_opened` fires but no opt-in event does. Don't add a
// `via`/emit param — give a new silent-opt-in surface its own named function.
export async function persistBannerAcknowledgeWithoutEmitting(): Promise<void> {
if (!storeRef) {
return
}
const settings = storeRef.getSettings()
// Fallback only used if the `telemetry` block is absent (migration invariant broken); updateSettings deep-merges it (persistence.ts:560).
storeRef.updateSettings({
telemetry: {
...(settings.telemetry ?? { installId: '', existedBeforeTelemetryRelease: true }),
optedIn: true
}
})
if (posthog) {
await posthog.optIn()
}
// Why: banner resolution is the first eligible moment for app_opened; SDK re-enabled above so capture sees the new consent.
trackAppOpenedOnce()
}
export function trackAppOpenedOnce(): void {
if (appOpenedTrackedThisSession) {
return
}
appOpenedTrackedThisSession = true
// Why: `nth_repo_added: 0` marks the session-zero / pre-repo cohort. See docs/onboarding-funnel-cohort-addendum.md.
track('app_opened', { ...getCohortAtEmit() })
}
export async function shutdownTelemetry(): Promise<void> {
// Set the gate before flush so late IPC-arrived tracks drop instead of enqueuing mid-flush.
shuttingDown = true
const instance = posthog
if (!instance) {
return
}
try {
// Bounded flush caps at 2s, so quit delay rises by at most that.
await instance.shutdown(2_000)
} catch (err) {
// Telemetry must never crash the app on quit. Swallow.
console.warn('[telemetry] shutdown error (ignored):', err)
}
}
// Test-only introspection: `_`-prefixed helpers inject a fake PostHog and observe captures; not a runtime API.
export function _setPostHogClientForTests(client: PostHog | null): void {
posthog = client
}
export function _setCommonPropsForTests(props: CommonProps | null): void {
commonProps = props
}
export function _setStoreForTests(store: Store | null): void {
storeRef = store
}
export function _setShuttingDownForTests(value: boolean): void {
shuttingDown = value
}
export function _getSessionIdForTests(): string | null {
return sessionId
}
export function _enableTransportForTests(enabled: boolean): void {
testTransportEnabled = enabled
}
export function _resetFirstAppOpenedFiredForTests(): void {
appOpenedTrackedThisSession = false
}