Skill Tracer
Skills › gstack › browse › telemetry.ts

telemetry.ts

115 lines · browse/src/telemetry.ts at 3f79d81

1/**
2 * Lightweight telemetry — DX D9 from /plan-devex-review.
3 *
4 * Piggybacks on ~/.gstack/analytics/skill-usage.jsonl pattern (existing
5 * gstack telemetry). Hostname + aggregate counters only; no body content,
6 * no agent text, no command args. Respects the user's telemetry tier
7 * setting (off | anonymous | community) via gstack-config.
8 *
9 * Fire-and-forget: never blocks the calling path. Errors swallowed.
10 *
11 * Events:
12 * domain_skill_saved {host, scope, state, bytes}
13 * domain_skill_state_changed {host, from_state, to_state}
14 * domain_skill_save_blocked {host, reason}
15 * domain_skill_fired {host, source, version}
16 * cdp_method_called {domain, method, allowed, scope}
17 * cdp_method_denied {domain, method} ← drives next allow-list growth
18 * cdp_method_lock_acquire_ms {domain, method, ms}
19 */
21import { promises as fs } from 'fs';
22import * as path from 'path';
23import { readGstackConfigYamlKey } from './config';
24import { resolveStateRoot } from '../../lib/state-root';
26function gstackHome(): string {
27 return resolveStateRoot();
30function analyticsDir(): string {
31 return path.join(gstackHome(), 'analytics');
34function telemetryFile(): string {
35 return path.join(analyticsDir(), 'browse-telemetry.jsonl');
38let lastEnsuredDir: string | null = null;
39async function ensureDir(): Promise<void> {
40 const dir = analyticsDir();
41 if (lastEnsuredDir === dir) return;
42 await fs.mkdir(dir, { recursive: true });
43 lastEnsuredDir = dir;
46let telemetryDisabled: boolean | null = null;
47/**
48 * Is telemetry disabled for this process? Telemetry is OPT-IN: the consent
49 * prompt writes a granted tier ('community' | 'anonymous') to
50 * ~/.gstack/config.yaml, and only a granted tier enables emission. Tiers,
51 * checked in order:
52 *
53 * 1. Env hint GSTACK_TELEMETRY_OFF=1 (set by preambles and test
54 * harnesses): always disabled, even over a granted config tier.
55 * 2. Persistent tier via the shared flat-YAML helper in config.ts (same
56 * parser as the pair-agent gate, so the two consent gates never drift):
57 * explicit `telemetry: off` disables; 'community'/'anonymous' enable.
58 * 3. Default: DISABLED. An absent key, absent file, or unrecognized value
59 * means consent was never granted — matching bin/gstack-config's
60 * DEFAULTS table, which reports 'off' for an unset telemetry key.
61 * Anything else would be a split-brain where `gstack-config get
62 * telemetry` tells the user 'off' while a direct-$B daemon emits.
63 * One escape hatch: GSTACK_TELEMETRY_OFF=0 is a harness-side consent
64 * assertion that flips this DEFAULT only (test harnesses exercising the
65 * write path against a scratch GSTACK_HOME) — it never overrides an
66 * explicit `telemetry: off` the user wrote.
67 *
68 * Exported so tests can pin the consent gate directly; the cached verdict
69 * resets via _resetTelemetryCache.
70 */
71export function isTelemetryDisabled(): boolean {
72 if (telemetryDisabled !== null) return telemetryDisabled;
73 // Env kill switch (set by preamble or test harnesses): beats everything.
74 if (process.env.GSTACK_TELEMETRY_OFF === '1') {
75 telemetryDisabled = true;
76 return true;
77 }
78 // Persistent tier: an explicit user-written value always wins next.
79 const tier = readGstackConfigYamlKey('telemetry');
80 if (tier === 'off') {
81 telemetryDisabled = true;
82 return true;
83 }
84 if (tier === 'community' || tier === 'anonymous') {
85 telemetryDisabled = false;
86 return false;
87 }
88 // No granted consent on record (absent key/file, unrecognized value):
89 // disabled — unless the harness asserted consent via the env seam.
90 telemetryDisabled = process.env.GSTACK_TELEMETRY_OFF !== '0';
91 return telemetryDisabled;
94export interface TelemetryEvent {
95 event: string;
96 [key: string]: unknown;
99/** Fire-and-forget log. Never throws. */
100export function logTelemetry(payload: TelemetryEvent): Promise<void> {
101 if (isTelemetryDisabled()) return Promise.resolve();
102 const enriched = { ...payload, ts: new Date().toISOString() };
103 return ensureDir()
104 .then(() => fs.appendFile(telemetryFile(), JSON.stringify(enriched) + '\n', 'utf8'))
105 .catch(() => {
106 // Telemetry must never crash the caller. If the disk is full or perms
107 // are wrong, swallow silently — there's nothing useful to do here.
108 });
111/** Test-only: reset cached state. */
112export function _resetTelemetryCache(): void {
113 telemetryDisabled = null;
114 lastEnsuredDir = null;