Files
open-design/apps/daemon/tests/update-apply-observations.test.ts
5b6fbb55ea [OPEND-3712] feat(telemetry): stability metrics for launch, project entry, and memory (#8805)
* feat(analytics): define client metrics schema and unify telemetry metadata

* feat(desktop): measure process startup phases through business readiness

Add launch_attempt, business_ready, and launch_result timing with monotonic
phase offsets, per-attempt correlation, shared app session identity, explicit
process/activation measurement boundaries, and idempotent terminal results.
Preserve packaged sidecar phases across the desktop handoff and report bounded
startup failures without blocking successful startup on telemetry.

Window mount/reveal alone does not declare business Ready. Preload/web owners
must wire od:startup:context and od:startup:business-ready with matching
launch_attempt_id/app_session_id plus required_data_ready/actions_ready=true.

Align with the shared event registry/schema and envelope fields (device_id,
event_id, app_session_id, client_type, capture_source, env, channel,
app_version, os_version, arch, occurred_at, reported_at, capability), plus
startup_type, launch_attempt_id, started_at, duration_ms, outcome, reason,
failure_stage, stage_offsets_ms, stage_durations_ms, measurement_start,
start_time_source, and os_launch_time_available. The existing daemon safety
bridge overwrites some envelope fields; shared-envelope alignment stays with
that owner.

Coverage limits: no OS icon-click timestamp or separate launcher-process span;
no new packaged launch report without real installation identity; failures
before packaged runtime context is armed and forced process exits can be
unobserved. Cold means a fresh desktop process; warm means hidden-window
restore; reopen means desktop restored from a headless owner. Measurement
acceptance and preload ownership need maintainer confirmation; native
QA/publication remain.

Validation (local Node 24 / pnpm 10.33.2):
- Red specs captured before implementation.
- desktop focused tests: 7 files, 47 passed.
- packaged focused tests: 6 files, 87 passed.
- pnpm --filter @open-design/desktop typecheck: passed.
- pnpm --filter @open-design/packaged typecheck: passed.
- pnpm guard: passed.
- pnpm typecheck: passed after building missing local workspace artifacts.
- git diff --cached --check: passed.

Local commit only. No web/memory-sampling changes or external operations.

* feat(web): measure project navigation intent to actionable ready

* feat(observability): add bounded periodic process memory sampling

* feat(telemetry): integrate launch readiness and bounded process memory metrics

Unify metric envelopes, classify process launches with a persisted heuristic, wire preload and renderer readiness, cancel abandoned navigation, and bind consent-aware memory sampling to the Electron lifecycle. Document the metric definitions and stabilize validation fixtures that inherited host configuration or raced signal-handler readiness.

* docs(telemetry): translate stability drafts to English

Rewrite the historical project-entry draft in English and neutralize the
stability-metrics intro so odcrew-facing docs stay English-only.

* fix(telemetry): distinguish Linux Node installs from packaged runtimes

* fix(telemetry): wait for committed conversation navigation before readiness

* fix(telemetry): exclude deferred headless restores from launch timing

* fix(telemetry): require current Home project and agent reads to succeed

* fix(telemetry): fail closed on damaged memory sampling consent

* docs(telemetry): note catalog read failures cannot block Home readiness

* feat(telemetry): prepare update stage and desktop restart observations

* feat(telemetry): prepare responsiveness collectors pending transport integration

* fix(desktop): cancel consent lookup and anchor memory sampling to ready

* fix(telemetry): classify and correlate crash and exit reports

* feat(telemetry): measure consented visible preview attempts

* feat(telemetry): wire page switch intent and readiness timing

* fix(telemetry): validate responsiveness metrics and fence revoked buffers

* fix(telemetry): require successful scoped Home resources before readiness

* fix(telemetry): clear chat health collection on consent withdrawal

* docs(telemetry): record integrated responsiveness validation and budgets

* fix(telemetry): admit update lifecycle stage metrics

* test(contracts): restore merged client metric schema cases

* fix(telemetry): route native stability metrics through safety capture

Remove metrics-consent checks from startup, memory, exit and update-stage
reporting while preserving product analytics consent and privacy projection.

* docs(telemetry): align stability metrics with safety-channel policy

Document consent-independent stability reporting and unchanged product/content
consent, privacy projection, budgets and remaining native QA.

* fix(telemetry): observe runtime daemon exits and correlate memory samples

Capture parent-observed daemon death through the safety channel, preserve stronger evidence across restart, and join exits to real packaged memory sampling with opaque process IDs and nullable V8 heap limits.

* fix(telemetry): send web stability metrics on the safety channel

Drop the metrics-consent gate from web responsiveness, preview and chat
health collectors to match the native safety-channel routing, and remove
test hooks for the deleted consent API.

* test(web): record an open intent before asserting a trusted first paint

* test(web): align component tests with commit-driven navigation and visible-preview gating

ProjectView switches conversations in the router onCommit callback, preview
diagnostics require a visible frame, and catalog/page-switch reads changed
their call shape; update mocks and expectations accordingly.

* fix(telemetry): require successful catalogs before reporting page navigation ready

A failed design-system/skill/template read cleared loading without marking the
catalog ready, so page switches still reported ready. Mirror the startup gate.

* fix(desktop): prune expired stage-only update check flows

Every scheduled check opens a metric flow directory. Remove stage-only flows
past the replay TTL during the existing startup scan; installer flows stay.

* fix(telemetry): isolate reporting from navigation and catalog fallback

* fix(telemetry): preserve business behavior with passive observation

* fix(telemetry): classify daemon channel by service packaged flag

On Linux CI the Node binary lives under /opt/, so execPath heuristics
marked unpackaged runs as packaged and reported channel=stable. Without
an explicit or version-inferred channel, derive it from the service's
packagedRuntime flag instead. Also restore the desktop SHOW-hook boundary
assertion to match activeDesktop.show().

* fix(telemetry): keep killed daemons and fresh-install launch attempts observable

OPEND-4221: the sidecar supervisor exited 0 whenever its target died from a
signal, so an external SIGKILL of the daemon reached packaged as
code=0/signal=null and was reported as clean-exit. The supervisor now exits 0
only for stops it forwarded itself and encodes any other terminating signal as
128 + N; observeDaemonExit decodes that back to reason=killed with the signal.

OPEND-4220: packaged flushed client_launch_attempt directly at daemon-ready and
dropped it when installation.json did not exist yet, which is always the case
on a fresh install. Launch events now stay buffered until desktop main arms the
daemon-backed reporter, and the daemon retains launch metrics (bounded, in
order) until the installation identity exists, replaying them before later
events. Explicit opt-out still delivers immediately on the safety channel.

* fix(telemetry): exclude url_load previews from the preview denominator

trackVisiblePreview probes the frame with od:preview-metric-probe and, after
15s without od:preview-metric-result, finishes the attempt as error /
probe_unverified. URL-loaded artifacts never receive the srcdoc observability
bridge, so they cannot answer; every visible, successful URL preview was
therefore counted as an error. Frames that cannot answer the probe are now
excluded from the denominator, matching how the PR scopes Live Artifact HTML.

* fix(telemetry): move renderer safety identity to the installation id on fresh install

OPEND-4220 retest: on a fresh install the renderer safety channel bootstraps
before the installation id is minted, falls back to the anonymous id, and kept
it for the whole session, so renderer stability events carried a different
device_id than native launch/memory events. setIdentity now also patches the
safety context, and a late bootstrap keeps the installation id.

---------

Co-authored-by: heggria <bshengtao@gmail.com>
2026-10-22 05:55:52 +00:00

195 lines
6.7 KiB
TypeScript

import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import {
classifyInstallerObservation,
installerObservationRoot,
normalizeUpdateObservationChannel,
observePendingInstallerApplyAttempts,
type InstallerObservationSummary,
} from '../src/migration/index.js';
function pendingSummary(overrides: Partial<InstallerObservationSummary> = {}): InstallerObservationSummary {
return {
arch: 'x64',
artifactType: 'installer',
attemptedAt: '2026-05-20T00:00:00.000Z',
channel: 'beta',
flowId: 'flow-1',
fromVersion: '0.8.0-beta.5',
kind: 'installer_apply_observation',
namespace: 'release-beta-win',
platform: 'win32',
reason: 'installer_open_requested',
result: 'pending',
schemaVersion: 1,
toVersion: '0.8.0-beta.6',
updatedAt: '2026-05-20T00:00:00.000Z',
...overrides,
};
}
async function writeSummary(dataRoot: string, summary: InstallerObservationSummary): Promise<string> {
const filePath = path.join(installerObservationRoot(dataRoot), summary.flowId, 'summary.json');
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
return filePath;
}
describe('update apply observations', () => {
it('classifies next-launch version matches and unchanged versions', () => {
const now = new Date('2026-05-20T00:03:00.000Z');
expect(classifyInstallerObservation(pendingSummary(), {
currentChannel: 'beta',
currentVersion: '0.8.0-beta.6',
namespace: 'release-beta-win',
now,
})).toMatchObject({
elapsedBucket: 'lt_5m',
reason: 'app_version_matches',
result: 'success',
});
expect(classifyInstallerObservation(pendingSummary(), {
currentChannel: 'beta',
currentVersion: '0.8.0-beta.5',
namespace: 'release-beta-win',
now,
})).toMatchObject({
reason: 'app_version_unchanged',
result: 'not_applied',
});
});
it('classifies mismatched runtime identity without using raw paths or errors', () => {
expect(classifyInstallerObservation(pendingSummary(), {
currentChannel: 'stable',
currentVersion: '0.8.0-beta.6',
namespace: 'release-beta-win',
now: new Date('2026-05-20T00:03:00.000Z'),
})).toMatchObject({
reason: 'identity_mismatch',
result: 'unknown',
});
});
it('normalizes prerelease and preview observation channels without collapsing them to beta', () => {
expect(normalizeUpdateObservationChannel('0.8.0-prerelease.2')).toBe('prerelease');
expect(normalizeUpdateObservationChannel('0.8.0-preview.2')).toBe('preview');
expect(normalizeUpdateObservationChannel('0.8.0', 'prerelease')).toBe('prerelease');
});
it.each(['installer', 'payload'] as const)(
'submits one sanitized %s analytics event and marks delivery metadata',
async (artifactType) => {
const dataRoot = await mkdtemp(path.join(tmpdir(), 'od-update-observe-'));
const capture = vi.fn();
try {
const summaryPath = await writeSummary(dataRoot, pendingSummary({ artifactType }));
await expect(observePendingInstallerApplyAttempts({
analytics: { capture },
appVersion: '0.8.0-beta.6',
currentChannel: 'beta',
currentVersion: '0.8.0-beta.6',
dataRoot,
env: { POSTHOG_KEY: 'ph_test' },
namespace: 'release-beta-win',
now: () => new Date('2026-05-20T00:03:00.000Z'),
readConfig: async () => ({
installationId: 'install-1',
telemetry: { metrics: true },
}),
})).resolves.toEqual({ observed: 1 });
expect(capture).toHaveBeenCalledTimes(1);
expect(capture.mock.calls[0]?.[0]).toMatchObject({
eventName: 'update_apply_observed',
appVersion: '0.8.0-beta.6',
insertId: 'update_apply_observed:flow-1',
properties: {
arch: 'x64',
artifact_type: artifactType,
channel: 'beta',
elapsed_bucket: 'lt_5m',
flow_id: 'flow-1',
from_version: '0.8.0-beta.5',
namespace: 'release-beta-win',
platform: 'win32',
reason: 'app_version_matches',
result: 'success',
to_version: '0.8.0-beta.6',
},
});
const updated = JSON.parse(await readFile(summaryPath, 'utf8')) as Record<string, unknown>;
expect(updated).toMatchObject({
delivery: {
eventName: 'update_apply_observed',
insertId: 'update_apply_observed:flow-1',
status: 'submitted',
},
reason: 'app_version_matches',
result: 'success',
});
await observePendingInstallerApplyAttempts({
analytics: { capture },
appVersion: '0.8.0-beta.6',
currentVersion: '0.8.0-beta.6',
dataRoot,
env: { POSTHOG_KEY: 'ph_test' },
namespace: 'release-beta-win',
});
expect(capture).toHaveBeenCalledTimes(1);
} finally {
await rm(dataRoot, { force: true, recursive: true });
}
});
it('marks no-consent observations without submitting analytics', async () => {
const dataRoot = await mkdtemp(path.join(tmpdir(), 'od-update-observe-'));
const capture = vi.fn();
try {
const summaryPath = await writeSummary(dataRoot, pendingSummary());
await observePendingInstallerApplyAttempts({
analytics: { capture },
appVersion: '0.8.0-beta.6',
currentVersion: '0.8.0-beta.6',
dataRoot,
env: { POSTHOG_KEY: 'ph_test' },
namespace: 'release-beta-win',
readConfig: async () => ({
installationId: null,
telemetry: { metrics: false },
}),
});
expect(capture).not.toHaveBeenCalled();
const updated = JSON.parse(await readFile(summaryPath, 'utf8')) as Record<string, unknown>;
expect(updated).toMatchObject({
delivery: {
eventName: 'update_apply_observed',
status: 'skipped_no_consent',
},
});
} finally {
await rm(dataRoot, { force: true, recursive: true });
}
});
});
it('fails closed on damaged consent rather than recovering the default opt-in', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'od-update-consent-'));
const capture = vi.fn();
try {
await writeSummary(root, pendingSummary());
await writeFile(path.join(root, 'app-config.json'), '{broken');
await observePendingInstallerApplyAttempts({ analytics: { capture }, appVersion: '0.8.0-beta.6',
currentVersion: '0.8.0-beta.6', dataRoot: root, namespace: 'release-beta-win', env: { POSTHOG_KEY: 'ph_test' } });
expect(capture).not.toHaveBeenCalled();
} finally { await rm(root, { force: true, recursive: true }); }
});