Files
thedotmack-claude-mem/tests/telemetry/scrub.test.ts
Alex Newman 4ce51efd28 feat(telemetry): reliability signals — retrieval quality, compression trust, worker lifecycle, hook failures (Plan 14) (#2874)
* feat(telemetry): disclose 19 reliability-signal fields and 2 new events across all surfaces

Whitelist (scrub.ts), scrub tests, public docs (telemetry.mdx), and CLI
disclosure (COLLECTED_FIELDS/EVENT_NAMES) for the Plan 14 reliability
signals: search retrieval quality, compression trust, worker lifecycle,
and hook failure keys, plus the worker_stopped and hook_failed events.
Includes the plan document.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(telemetry): retrieval-quality signals on search_performed

SearchManager.search() fills an optional telemetry envelope
(result_count, search_strategy, chroma_available, fallback_reason)
across all three search paths; handlers stash it on
res.locals.searchTelemetry and the existing finish-middleware spreads it
into the search_performed capture. Zero-result searches report
result_count: 0; Chroma fallback reasons are a closed enum, never the
error message. Response shapes unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(telemetry): compression trust signals on session_compressed

fabrication_detected/fabricated_count flow through compressionProps (all
three emit paths); invalid-output respawns emit a respawn-gated
session_compressed with outcome invalid_output and the classifier value;
aborted generators emit outcome aborted with abort_reason normalized to
a closed enum in the .finally where all five abort flows converge (the
.catch path can never observe a non-null abortReason).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(telemetry): worker lifecycle signals — worker_stopped, crash detection, memory metrics

Clean-shutdown sentinel written before telemetry flush and consumed at
startup; worker_started gains previous_shutdown (crash/clean/unknown)
and previous_uptime_seconds derived from the stale PID file; new
worker_stopped event (uptime_seconds, shutdown_reason stop/restart/
signal) emitted before shutdownTelemetry(); the CLI restart path tags
/api/admin/shutdown?reason=restart so restarts are distinguishable;
buildLifecycleProps adds integer process_rss_mb/heap_used_mb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(telemetry): threshold-gated hook_failed distress signal via CLI transport

recordWorkerUnreachable emits hook_failed exactly when the consecutive-
failure count reaches the fail-loud threshold; the generic blocking-error
branch emits error_mode blocking_error. Both emits are awaited before
the process.exit paths so the 2s-capped CLI POST survives; hook_type is
a closed enum registered at hookCommand entry. Exit codes unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(build): regenerate plugin artifacts with Plan 14 telemetry signals

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tests): make PostHog client regression test order-independent via global preload mock

The disableGeoip regression test mocked posthog-node per-file, but
telemetry.ts is imported transitively by many test files in the shared
bun process, so the mock registered too late and the test failed in
full-suite runs — CI on main has been red since v13.5.4. The mock now
registers in a bunfig [test].preload before any module loads, which
also guarantees test runs can never construct a real PostHog client and
flush fabricated events into production analytics (consent is
default-on and the suite outlives flushInterval). telemetry.ts gains a
test-only state reset so construction is observed deterministically
regardless of suite order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(telemetry): forward shutdown reason in Windows-managed IPC message

Review follow-up: the wrapper IPC path discarded the restart tag, so an
external Windows wrapper could only ever report shutdown_reason 'stop'.
No wrapper in this repo listens for the message, but the reason now
travels with it for any that does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 02:51:45 -07:00

328 lines
9.1 KiB
TypeScript

import { describe, it, expect } from 'bun:test';
import { scrubProperties, ALLOWED_PROPERTY_KEYS } from '../../src/services/telemetry/scrub';
describe('scrubProperties', () => {
it('keeps whitelisted keys with primitive values', () => {
const result = scrubProperties({
version: '13.4.2',
os: 'darwin',
arch: 'arm64',
runtime: 'bun',
runtime_version: '1.2.0',
duration_ms: 1234,
outcome: 'success',
error_category: 'timeout',
locale: 'en-US',
is_ci: false,
});
expect(result).toEqual({
version: '13.4.2',
os: 'darwin',
arch: 'arm64',
runtime: 'bun',
runtime_version: '1.2.0',
duration_ms: 1234,
outcome: 'success',
error_category: 'timeout',
locale: 'en-US',
is_ci: false,
});
});
it('keeps the funnel/feature keys with primitive values', () => {
const result = scrubProperties({
endpoint: 'by-file',
ide: 'claude-code',
provider: 'claude',
runtime_mode: 'worker',
trigger: 'heartbeat',
count: 7,
has_summary: true,
is_update: false,
});
expect(result).toEqual({
endpoint: 'by-file',
ide: 'claude-code',
provider: 'claude',
runtime_mode: 'worker',
trigger: 'heartbeat',
count: 7,
has_summary: true,
is_update: false,
});
});
it('keeps the platform/toolchain keys with primitive values', () => {
const result = scrubProperties({
os_version: '10.0.22631',
is_wsl: false,
node_version: '22.14.0',
interactive: true,
install_method: 'npm',
bun_version: '1.3.9',
uv_version: '0.7.2',
claude_code_version: '2.0.14',
});
expect(result).toEqual({
os_version: '10.0.22631',
is_wsl: false,
node_version: '22.14.0',
interactive: true,
install_method: 'npm',
bun_version: '1.3.9',
uv_version: '0.7.2',
claude_code_version: '2.0.14',
});
});
it('keeps the depth/economics keys with primitive values', () => {
const result = scrubProperties({
observation_count: 50,
session_count: 12,
timeline_depth_days: 90,
has_session_summary: true,
obs_type_bugfix: 3,
obs_type_other: 1,
tokens_injected: 17914,
tokens_saved_vs_naive: 144379,
mode: 'code',
search_strategy: 'timeline',
observation_type: 'bugfix',
hook: 'ingest',
compression_ms: 2140,
tokens_input: 5800,
tokens_output: 420,
compression_ratio: 13.81,
model: 'claude-haiku-4-5',
});
expect(Object.keys(result)).toHaveLength(17);
expect(result.tokens_saved_vs_naive).toBe(144379);
expect(result.hook).toBe('ingest');
expect(result.model).toBe('claude-haiku-4-5');
});
it('keeps the cost/endpoint keys with primitive values', () => {
const result = scrubProperties({
cost_usd: 0.0021,
endpoint_class: 'openrouter',
});
expect(result).toEqual({
cost_usd: 0.0021,
endpoint_class: 'openrouter',
});
});
it('keeps the install snapshot keys with primitive values', () => {
const result = scrubProperties({
db_observation_count: 92501,
db_session_count: 5243,
db_summary_count: 9698,
db_project_count: 379,
db_size_mb: 364.4,
install_age_days: 104,
obs_count_7d: 1887,
obs_count_30d: 10357,
days_since_last_obs: 0,
});
expect(Object.keys(result)).toHaveLength(9);
expect(result.db_observation_count).toBe(92501);
expect(result.install_age_days).toBe(104);
expect(result.days_since_last_obs).toBe(0);
});
it('keeps the retrieval quality keys with primitive values', () => {
const result = scrubProperties({
result_count: 0,
chroma_available: false,
fallback_reason: 'chroma_connection',
});
expect(result).toEqual({
result_count: 0,
chroma_available: false,
fallback_reason: 'chroma_connection',
});
});
it('keeps the compression trust keys with primitive values', () => {
const result = scrubProperties({
fabrication_detected: true,
fabricated_count: 2,
invalid_output_class: 'poisoned',
consecutive_invalid_outputs: 3,
respawn_triggered: true,
abort_reason: 'restart_guard',
});
expect(Object.keys(result)).toHaveLength(6);
expect(result.fabrication_detected).toBe(true);
expect(result.fabricated_count).toBe(2);
expect(result.invalid_output_class).toBe('poisoned');
expect(result.consecutive_invalid_outputs).toBe(3);
expect(result.respawn_triggered).toBe(true);
expect(result.abort_reason).toBe('restart_guard');
});
it('keeps the worker lifecycle keys with primitive values', () => {
const result = scrubProperties({
previous_shutdown: 'crash',
previous_uptime_seconds: 86400,
uptime_seconds: 3600,
shutdown_reason: 'restart',
process_rss_mb: 187,
heap_used_mb: 92,
});
expect(Object.keys(result)).toHaveLength(6);
expect(result.previous_shutdown).toBe('crash');
expect(result.previous_uptime_seconds).toBe(86400);
expect(result.uptime_seconds).toBe(3600);
expect(result.shutdown_reason).toBe('restart');
expect(result.process_rss_mb).toBe(187);
expect(result.heap_used_mb).toBe(92);
});
it('keeps the hook failure keys with primitive values', () => {
const result = scrubProperties({
hook_type: 'observation',
error_mode: 'worker_unavailable',
consecutive_failures: 3,
threshold_tripped: true,
});
expect(result).toEqual({
hook_type: 'observation',
error_mode: 'worker_unavailable',
consecutive_failures: 3,
threshold_tripped: true,
});
});
it('drops unknown keys silently', () => {
const result = scrubProperties({
version: '1.0.0',
session_id: 'abc-123',
random_key: 'value',
});
expect(result).toEqual({ version: '1.0.0' });
});
it('drops sensitive-looking keys even if present', () => {
const result = scrubProperties({
path: '/Users/alice/secret-project/index.ts',
cwd: '/Users/alice/secret-project',
prompt: 'fix my auth bug',
query: 'password reset flow',
project_name: 'secret-project',
email: 'alice@example.com',
ip: '203.0.113.7',
outcome: 'success',
});
expect(result).toEqual({ outcome: 'success' });
expect(Object.keys(result)).not.toContain('path');
expect(Object.keys(result)).not.toContain('cwd');
expect(Object.keys(result)).not.toContain('prompt');
expect(Object.keys(result)).not.toContain('query');
expect(Object.keys(result)).not.toContain('project_name');
expect(Object.keys(result)).not.toContain('email');
expect(Object.keys(result)).not.toContain('ip');
});
it('whitelist never contains sensitive keys', () => {
for (const key of ['path', 'cwd', 'prompt', 'query', 'project_name', 'email', 'ip']) {
expect(ALLOWED_PROPERTY_KEYS.has(key)).toBe(false);
}
});
it('drops nested objects on whitelisted keys', () => {
const result = scrubProperties({
outcome: { status: 'ok', detail: '/some/path' },
version: '1.0.0',
});
expect(result).toEqual({ version: '1.0.0' });
});
it('drops arrays on whitelisted keys', () => {
const result = scrubProperties({
outcome: ['a', 'b'],
duration_ms: 5,
});
expect(result).toEqual({ duration_ms: 5 });
});
it('drops functions on whitelisted keys', () => {
const result = scrubProperties({
outcome: () => 'success',
version: '1.0.0',
});
expect(result).toEqual({ version: '1.0.0' });
});
it('drops null and undefined values', () => {
const result = scrubProperties({
outcome: null,
error_category: undefined,
version: '1.0.0',
});
expect(result).toEqual({ version: '1.0.0' });
});
it('drops NaN and Infinity', () => {
const result = scrubProperties({
duration_ms: NaN,
version: '1.0.0',
});
expect(result).toEqual({ version: '1.0.0' });
expect(scrubProperties({ duration_ms: Infinity })).toEqual({});
});
it('truncates strings longer than 200 characters', () => {
const long = 'x'.repeat(500);
const result = scrubProperties({ outcome: long });
expect(result.outcome).toBe('x'.repeat(200));
expect((result.outcome as string).length).toBe(200);
});
it('leaves strings of exactly 200 characters untouched', () => {
const exact = 'y'.repeat(200);
const result = scrubProperties({ outcome: exact });
expect(result.outcome).toBe(exact);
});
it('returns an empty object for empty input', () => {
expect(scrubProperties({})).toEqual({});
});
it('never throws on hostile input', () => {
expect(scrubProperties(null as unknown as Record<string, unknown>)).toEqual({});
expect(scrubProperties(undefined as unknown as Record<string, unknown>)).toEqual({});
const hostile: Record<string, unknown> = {};
Object.defineProperty(hostile, 'outcome', {
enumerable: true,
get() {
throw new Error('gotcha');
},
});
expect(scrubProperties(hostile)).toEqual({});
});
});