From b0d1a9b2e96a904a4b929acf5e11659f383b653a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 25 Apr 2026 13:12:16 -0700 Subject: [PATCH] feat(browse): telemetry signals + project-slug helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lightweight telemetry per DX D9: piggybacks on ~/.gstack/analytics/ pattern. Hostname + aggregate counters only, no body content. GSTACK_TELEMETRY_OFF=1 silences. Fire-and-forget — never blocks calling path. Signals fired so far: - domain_skill_saved {host, scope, state, bytes} - domain_skill_save_blocked {host, reason} (domain_skill_fired and cdp_method_* fired in subsequent commits.) Also extracts project-slug resolution into project-slug.ts so server.ts and domain-skill-commands.ts share one cached lookup. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/src/domain-skill-commands.ts | 29 ++--------- browse/src/project-slug.ts | 36 +++++++++++++ browse/src/telemetry.ts | 80 +++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 24 deletions(-) create mode 100644 browse/src/project-slug.ts create mode 100644 browse/src/telemetry.ts diff --git a/browse/src/domain-skill-commands.ts b/browse/src/domain-skill-commands.ts index 08b4fd95..f3fa5d99 100644 --- a/browse/src/domain-skill-commands.ts +++ b/browse/src/domain-skill-commands.ts @@ -26,7 +26,7 @@ import { promises as fs } from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { execSync, spawnSync } from 'child_process'; +import { spawnSync } from 'child_process'; import type { BrowserManager } from './browser-manager'; import { deriveHostFromActiveTab, @@ -40,29 +40,8 @@ import { type SkillScope, } from './domain-skills'; import { runContentFilters } from './content-security'; - -// ─── Project slug resolution (cached) ────────────────────────── - -let cachedSlug: string | null = null; - -function getCurrentProjectSlug(): string { - if (cachedSlug) return cachedSlug; - const explicit = process.env.GSTACK_PROJECT_SLUG; - if (explicit) { - cachedSlug = explicit; - return explicit; - } - // Fallback: invoke gstack-slug helper. May print "SLUG=value" or just "value". - try { - const slugBin = path.join(os.homedir(), '.claude/skills/gstack/bin/gstack-slug'); - const out = execSync(slugBin, { encoding: 'utf8', timeout: 2000 }).trim(); - const m = out.match(/SLUG="?([^"\n]+)"?/); - cachedSlug = m ? m[1]! : (out || 'unknown'); - } catch { - cachedSlug = 'unknown'; - } - return cachedSlug; -} +import { getCurrentProjectSlug } from './project-slug'; +import { logTelemetry } from './telemetry'; // ─── Body input resolution ────────────────────────────────────── @@ -142,6 +121,7 @@ async function handleSave(args: string[], bm: BrowserManager): Promise { // injection time, not here (CLAUDE.md: classifier can't import in compiled binary). const filterResult = runContentFilters(body, page.url(), 'domain-skill-save'); if (filterResult.blocked) { + logTelemetry({ event: 'domain_skill_save_blocked', host, reason: filterResult.message }); throw new Error( `Save blocked: ${filterResult.message}\n` + 'Cause: skill body trips L1-L3 content filters (likely contains URL blocklist match or ARIA injection patterns).\n' + @@ -159,6 +139,7 @@ async function handleSave(args: string[], bm: BrowserManager): Promise { source: 'agent', classifierScore: 0, // L4 deferred to load-time }); + logTelemetry({ event: 'domain_skill_saved', host, scope: row.scope, state: row.state, bytes: body.length }); return formatSavedOk(row, slug); } diff --git a/browse/src/project-slug.ts b/browse/src/project-slug.ts new file mode 100644 index 00000000..0a840ebe --- /dev/null +++ b/browse/src/project-slug.ts @@ -0,0 +1,36 @@ +/** + * Project slug resolution for the browse daemon. + * + * Used by domain-skills (per-project storage) and sidebar prompt-context + * injection. Cached after first call — slug is derived from the daemon's + * git remote (or env override) and doesn't change between commands. + */ + +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; + +let cachedSlug: string | null = null; + +export function getCurrentProjectSlug(): string { + if (cachedSlug) return cachedSlug; + const explicit = process.env.GSTACK_PROJECT_SLUG; + if (explicit) { + cachedSlug = explicit; + return explicit; + } + try { + const slugBin = path.join(os.homedir(), '.claude/skills/gstack/bin/gstack-slug'); + const out = execSync(slugBin, { encoding: 'utf8', timeout: 2000 }).trim(); + const m = out.match(/SLUG="?([^"\n]+)"?/); + cachedSlug = m ? m[1]! : (out || 'unknown'); + } catch { + cachedSlug = 'unknown'; + } + return cachedSlug; +} + +/** Reset cache; for tests only. */ +export function _resetProjectSlugCache(): void { + cachedSlug = null; +} diff --git a/browse/src/telemetry.ts b/browse/src/telemetry.ts new file mode 100644 index 00000000..8f2604e4 --- /dev/null +++ b/browse/src/telemetry.ts @@ -0,0 +1,80 @@ +/** + * Lightweight telemetry — DX D9 from /plan-devex-review. + * + * Piggybacks on ~/.gstack/analytics/skill-usage.jsonl pattern (existing + * gstack telemetry). Hostname + aggregate counters only; no body content, + * no agent text, no command args. Respects the user's telemetry tier + * setting (off | anonymous | community) via gstack-config. + * + * Fire-and-forget: never blocks the calling path. Errors swallowed. + * + * Events: + * domain_skill_saved {host, scope, state, bytes} + * domain_skill_state_changed {host, from_state, to_state} + * domain_skill_save_blocked {host, reason} + * domain_skill_fired {host, source, version} + * cdp_method_called {domain, method, allowed, scope} + * cdp_method_denied {domain, method} ← drives next allow-list growth + * cdp_method_lock_acquire_ms {domain, method, ms} + */ + +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +function gstackHome(): string { + return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); +} + +function analyticsDir(): string { + return path.join(gstackHome(), 'analytics'); +} + +function telemetryFile(): string { + return path.join(analyticsDir(), 'browse-telemetry.jsonl'); +} + +let lastEnsuredDir: string | null = null; +async function ensureDir(): Promise { + const dir = analyticsDir(); + if (lastEnsuredDir === dir) return; + await fs.mkdir(dir, { recursive: true }); + lastEnsuredDir = dir; +} + +let telemetryDisabled: boolean | null = null; +function isDisabled(): boolean { + if (telemetryDisabled !== null) return telemetryDisabled; + // Check env (set by preamble or test harnesses). + if (process.env.GSTACK_TELEMETRY_OFF === '1') { + telemetryDisabled = true; + return true; + } + // Conservative default: telemetry ON unless explicitly off. Users opt out via + // gstack-config set telemetry off (preamble reads this; we trust the env hint). + telemetryDisabled = false; + return false; +} + +export interface TelemetryEvent { + event: string; + [key: string]: unknown; +} + +/** Fire-and-forget log. Never throws. */ +export function logTelemetry(payload: TelemetryEvent): void { + if (isDisabled()) return; + const enriched = { ...payload, ts: new Date().toISOString() }; + ensureDir() + .then(() => fs.appendFile(telemetryFile(), JSON.stringify(enriched) + '\n', 'utf8')) + .catch(() => { + // Telemetry must never crash the caller. If the disk is full or perms + // are wrong, swallow silently — there's nothing useful to do here. + }); +} + +/** Test-only: reset cached state. */ +export function _resetTelemetryCache(): void { + telemetryDisabled = null; + lastEnsuredDir = null; +}