mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
* chore(dead-code): drop 2k lines of unreachable exports and orphan modules Ran knip across every build entry (main, preload, renderer, popout, web, cli, relay, workers, forked sidecars, config scripts) and removed what no entry graph can reach. - 11 orphan modules nothing imported, plus one test that only covered them - 159 unused exports/types, with their now-dead helpers, imports and tests Each candidate was verified against dynamic references before deletion. 42 knip hits were false positives and are kept: shared modules consumed by the mobile/ workspace, the src/shared/plugins/** public API, vendored shadcn primitives, and relay wire-protocol constants held for compatibility. Adds knip.json + `pnpm audit:dead-code` so this stays measurable. Verified: pnpm typecheck, pnpm lint, and 2081 tests across the 73 affected test files all pass. * chore(dead-code): move knip config under config/ Root-level additions are blocked by the root directory guard. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
// 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 _enableTransportForTests(enabled: boolean): void {
|
||
testTransportEnabled = enabled
|
||
}
|
||
|
||
export function _resetFirstAppOpenedFiredForTests(): void {
|
||
appOpenedTrackedThisSession = false
|
||
}
|