From f1e8cb157ab8b59d06a77610d3cb201ed3867fdd Mon Sep 17 00:00:00 2001 From: lefarcen <935902669@qq.com> Date: Wed, 15 Jul 2026 16:08:10 +0800 Subject: [PATCH] fix(packaged): label main-process startup telemetry production, not development (#5681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `packaged_runtime_failed` event was tagged `env: development` in production — 1,241 development vs 2 production over 3 days — so real packaged startup crashes were filtered out of the env=production dashboards and the daily stability report couldn't tell real users from dev machines. Cause: the daemon child is always spawned with NODE_ENV=production (spawnSidecarChild), so daemon/web events correctly report `production`. But the packaged MAIN process — which emits packaged_runtime_failed — has no NODE_ENV of its own, and resolveTelemetryEnv defaulted an unset NODE_ENV to `development`. apps/packaged only ever runs as a packaged build, so that default was wrong. Fix: resolveTelemetryEnv now treats anything that isn't an explicit development marker as `production`, matching the daemon it spawns. Explicit overrides (OD_TELEMETRY_ENV / OPEN_DESIGN_ENV / POSTHOG_ENV / LANGFUSE_ENVIRONMENT) still win. Red-spec tests: env resolves to production by default (unset NODE_ENV) and honors an explicit override. --- apps/packaged/src/startup-telemetry.ts | 19 ++++-- apps/packaged/tests/startup-telemetry.test.ts | 66 +++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/apps/packaged/src/startup-telemetry.ts b/apps/packaged/src/startup-telemetry.ts index d07d38cff9..a81120c3a8 100644 --- a/apps/packaged/src/startup-telemetry.ts +++ b/apps/packaged/src/startup-telemetry.ts @@ -62,9 +62,18 @@ const EVENT_SCHEMA_VERSION = 2; const CLIENT_TYPE = "packaged_main"; const CAPTURE_SOURCE = "packaged/startup"; -// Replicates apps/daemon/src/telemetry-environment.ts (daemon src, not -// importable here) so the packaged main process resolves the same `env` bucket -// as daemon-emitted events for a given process environment. +// Resolve the same `env` bucket as the daemon events this main process's own +// sidecars emit. The daemon child is ALWAYS spawned with NODE_ENV=production +// (see spawnSidecarChild in sidecars.ts), so daemon/web events report +// `production` — but the packaged MAIN process's own NODE_ENV is unset, so the +// old `development` default mislabeled every packaged_runtime_failed as dev +// (100% 'development' in prod), hiding real startup crashes from the +// env=production dashboards. apps/packaged only ever runs as a packaged build, +// so an unset NODE_ENV here means packaged production, not dev; treat anything +// that isn't an explicit development marker as production so the two sides +// match. Explicit overrides (OD_TELEMETRY_ENV / OPEN_DESIGN_ENV / POSTHOG_ENV / +// LANGFUSE_ENVIRONMENT) still win for anyone who needs to force a bucket +// (e.g. a maintainer smoke-testing a local packaged build). function resolveTelemetryEnv(env: NodeJS.ProcessEnv = process.env): string { const explicit = env.OD_TELEMETRY_ENV?.trim() || @@ -72,8 +81,8 @@ function resolveTelemetryEnv(env: NodeJS.ProcessEnv = process.env): string { env.POSTHOG_ENV?.trim() || env.LANGFUSE_ENVIRONMENT?.trim(); if (explicit) return explicit; - if (env.NODE_ENV === "production") return "production"; - return "development"; + if (env.NODE_ENV === "development") return "development"; + return "production"; } // Mirrors apps/daemon/src/analytics.ts randomInsertId. $insert_id is PostHog's diff --git a/apps/packaged/tests/startup-telemetry.test.ts b/apps/packaged/tests/startup-telemetry.test.ts index f2bafdb4be..6f655c8c4d 100644 --- a/apps/packaged/tests/startup-telemetry.test.ts +++ b/apps/packaged/tests/startup-telemetry.test.ts @@ -248,6 +248,72 @@ describe('captureStartupFailure', () => { expect(typeof p.env).toBe('string'); }); + it('labels main-process events production (unset NODE_ENV) so they match daemon events', async () => { + // The packaged main process has no NODE_ENV of its own, while the daemon it + // spawns runs with NODE_ENV=production. Without this the event reported + // `development` — 100% of packaged_runtime_failed were mislabeled dev in + // prod and filtered out of the env=production dashboards. + const saved = { + OD_TELEMETRY_ENV: process.env.OD_TELEMETRY_ENV, + OPEN_DESIGN_ENV: process.env.OPEN_DESIGN_ENV, + POSTHOG_ENV: process.env.POSTHOG_ENV, + LANGFUSE_ENVIRONMENT: process.env.LANGFUSE_ENVIRONMENT, + NODE_ENV: process.env.NODE_ENV, + }; + delete process.env.OD_TELEMETRY_ENV; + delete process.env.OPEN_DESIGN_ENV; + delete process.env.POSTHOG_ENV; + delete process.env.LANGFUSE_ENVIRONMENT; + delete process.env.NODE_ENV; + try { + const fetchImpl = vi.fn().mockResolvedValue(new Response('ok')); + await captureStartupFailure( + { + posthogKey: 'phc_test', + posthogHost: null, + distinctId: 'install-123', + event: STARTUP_FAILURE_EVENT, + properties: { failure_kind: 'daemon-start' }, + }, + { fetchImpl: fetchImpl as unknown as typeof fetch, now: () => '2026-06-23T00:00:00.000Z' }, + ); + const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + const p = (JSON.parse(init.body as string) as { properties: Record }) + .properties; + expect(p.env).toBe('production'); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } + }); + + it('honors an explicit OD_TELEMETRY_ENV override (local smoke test can force dev)', async () => { + const savedOd = process.env.OD_TELEMETRY_ENV; + process.env.OD_TELEMETRY_ENV = 'development'; + try { + const fetchImpl = vi.fn().mockResolvedValue(new Response('ok')); + await captureStartupFailure( + { + posthogKey: 'phc_test', + posthogHost: null, + distinctId: 'install-123', + event: STARTUP_FAILURE_EVENT, + properties: { failure_kind: 'daemon-start' }, + }, + { fetchImpl: fetchImpl as unknown as typeof fetch, now: () => '2026-06-23T00:00:00.000Z' }, + ); + const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + const p = (JSON.parse(init.body as string) as { properties: Record }) + .properties; + expect(p.env).toBe('development'); + } finally { + if (savedOd === undefined) delete process.env.OD_TELEMETRY_ENV; + else process.env.OD_TELEMETRY_ENV = savedOd; + } + }); + it('never blocks past the timeout even if fetch hangs forever', async () => { // The critical guarantee for "no startup side-effect": a hung (offline) // request must not wedge the exit. Race must resolve via the timeout.