mirror of https://github.com/garrytan/gstack.git
feat(browse): telemetry signals + project-slug helper
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) <noreply@anthropic.com>
This commit is contained in:
parent
d2a4ea0b6a
commit
b0d1a9b2e9
|
|
@ -26,7 +26,7 @@
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { execSync, spawnSync } from 'child_process';
|
import { spawnSync } from 'child_process';
|
||||||
import type { BrowserManager } from './browser-manager';
|
import type { BrowserManager } from './browser-manager';
|
||||||
import {
|
import {
|
||||||
deriveHostFromActiveTab,
|
deriveHostFromActiveTab,
|
||||||
|
|
@ -40,29 +40,8 @@ import {
|
||||||
type SkillScope,
|
type SkillScope,
|
||||||
} from './domain-skills';
|
} from './domain-skills';
|
||||||
import { runContentFilters } from './content-security';
|
import { runContentFilters } from './content-security';
|
||||||
|
import { getCurrentProjectSlug } from './project-slug';
|
||||||
// ─── Project slug resolution (cached) ──────────────────────────
|
import { logTelemetry } from './telemetry';
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Body input resolution ──────────────────────────────────────
|
// ─── Body input resolution ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -142,6 +121,7 @@ async function handleSave(args: string[], bm: BrowserManager): Promise<string> {
|
||||||
// injection time, not here (CLAUDE.md: classifier can't import in compiled binary).
|
// injection time, not here (CLAUDE.md: classifier can't import in compiled binary).
|
||||||
const filterResult = runContentFilters(body, page.url(), 'domain-skill-save');
|
const filterResult = runContentFilters(body, page.url(), 'domain-skill-save');
|
||||||
if (filterResult.blocked) {
|
if (filterResult.blocked) {
|
||||||
|
logTelemetry({ event: 'domain_skill_save_blocked', host, reason: filterResult.message });
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Save blocked: ${filterResult.message}\n` +
|
`Save blocked: ${filterResult.message}\n` +
|
||||||
'Cause: skill body trips L1-L3 content filters (likely contains URL blocklist match or ARIA injection patterns).\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<string> {
|
||||||
source: 'agent',
|
source: 'agent',
|
||||||
classifierScore: 0, // L4 deferred to load-time
|
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);
|
return formatSavedOk(row, slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue