mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
3b22fc39e6
* feat: add gstack-telemetry-log and gstack-analytics scripts Local telemetry infrastructure for gstack usage tracking. gstack-telemetry-log appends JSONL events with skill name, duration, outcome, session ID, and platform info. Supports off/anonymous/community privacy tiers. gstack-analytics renders a personal usage dashboard from local data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add telemetry preamble injection + opt-in prompt + epilogue Extends generatePreamble() with telemetry start block (config read, timer, session ID, .pending marker), opt-in prompt (gated by .telemetry-prompted), and epilogue instructions for Claude to log events after skill completion. Adds 5 telemetry tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate all SKILL.md files with telemetry blocks Automated regeneration from gen-skill-docs.ts changes. All skills now include telemetry start block, opt-in prompt, and epilogue. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add Supabase schema, edge functions, and SQL views Telemetry backend infrastructure: telemetry_events table with RLS (insert-only), installations table for retention tracking, update_checks for install pings. Edge functions for update-check (version + ping), telemetry-ingest (batch insert), and community-pulse (weekly active count). SQL views for crash clustering and skill co-occurrence sequences. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add telemetry-sync, community-dashboard, and integration tests gstack-telemetry-sync: fire-and-forget JSONL → Supabase sync with privacy tier field stripping, batch limits, and cursor tracking. gstack-community-dashboard: CLI tool querying Supabase for skill popularity, crash clusters, and version distribution. 19 integration tests covering all telemetry scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: session-specific .pending markers + crash_clusters view fix Addresses Codex review findings: - .pending race condition: use .pending-$SESSION_ID instead of shared .pending file to prevent concurrent session interference - crash_clusters view: add total_occurrences and anonymous_occurrences columns since anonymous tier has no installation_id - Added test: own session pending marker is not finalized Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: dual-attempt update check with Supabase install ping Fires a parallel background curl to Supabase during the slow-path version fetch. Logs upgrade_prompted event only on fresh fetches (not cached replays) to avoid overcounting. GitHub remains the primary version source — Supabase ping is fire-and-forget. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: integrate telemetry usage stats into /retro output Retro now reads ~/.gstack/analytics/skill-usage.jsonl and includes gstack usage metrics (skill run counts, top skills, success rate) in the weekly retrospective output. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: move 'Skill usage telemetry' to Completed in TODOS.md Implemented in this branch: local JSONL logging, opt-in prompt, privacy tiers, Supabase backend, community dashboard, /retro integration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: wire Supabase credentials and expose tables via Data API Add supabase/config.sh with project URL and publishable key (safe to commit — RLS restricts to INSERT only). Update telemetry-sync, community-dashboard, and update-check to source the config and include proper auth headers for the Supabase REST API. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add SELECT RLS policies to migration for community dashboard reads All telemetry data is anonymous (no PII), so public reads via the publishable key are safe. Needed for the community dashboard to query skill popularity, crash clusters, and version distribution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.8.6) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: analytics backward-compatible with old JSONL format Handle old-format events (no event_type field) alongside new format. Skip hook_fire events. Fix grep -c whitespace issues and unbound variable errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: map JSONL field names to Postgres columns in telemetry-sync Local JSONL uses short names (v, ts, sessions) but the Supabase table expects full names (schema_version, event_timestamp, concurrent_sessions). Add sed mapping during field stripping. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Codex adversarial findings — cursor, opt-out, queries - Sync cursor now advances on HTTP 2xx (not grep for "inserted") - Update-check respects telemetry opt-out before pinging Supabase - Dashboard queries use correct view column names (total_occurrences) - Sync strips old-format "repo" field to prevent privacy leak Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add Privacy & Telemetry section to README Transparent disclosure of what telemetry collects, what it never sends, how to opt out, and a link to the schema so users can verify. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { execSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN = path.join(ROOT, 'bin');
|
|
|
|
// Each test gets a fresh temp directory for GSTACK_STATE_DIR
|
|
let tmpDir: string;
|
|
|
|
function run(cmd: string, env: Record<string, string> = {}): string {
|
|
return execSync(cmd, {
|
|
cwd: ROOT,
|
|
env: { ...process.env, GSTACK_STATE_DIR: tmpDir, GSTACK_DIR: ROOT, ...env },
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
}).trim();
|
|
}
|
|
|
|
function setConfig(key: string, value: string) {
|
|
run(`${BIN}/gstack-config set ${key} ${value}`);
|
|
}
|
|
|
|
function readJsonl(): string[] {
|
|
const file = path.join(tmpDir, 'analytics', 'skill-usage.jsonl');
|
|
if (!fs.existsSync(file)) return [];
|
|
return fs.readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean);
|
|
}
|
|
|
|
function parseJsonl(): any[] {
|
|
return readJsonl().map(line => JSON.parse(line));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-tel-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('gstack-telemetry-log', () => {
|
|
test('appends valid JSONL when tier=anonymous', () => {
|
|
setConfig('telemetry', 'anonymous');
|
|
run(`${BIN}/gstack-telemetry-log --skill qa --duration 142 --outcome success --session-id test-123`);
|
|
|
|
const events = parseJsonl();
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].v).toBe(1);
|
|
expect(events[0].skill).toBe('qa');
|
|
expect(events[0].duration_s).toBe(142);
|
|
expect(events[0].outcome).toBe('success');
|
|
expect(events[0].session_id).toBe('test-123');
|
|
expect(events[0].event_type).toBe('skill_run');
|
|
expect(events[0].os).toBeTruthy();
|
|
expect(events[0].gstack_version).toBeTruthy();
|
|
});
|
|
|
|
test('produces no output when tier=off', () => {
|
|
setConfig('telemetry', 'off');
|
|
run(`${BIN}/gstack-telemetry-log --skill ship --duration 30 --outcome success --session-id test-456`);
|
|
|
|
expect(readJsonl()).toHaveLength(0);
|
|
});
|
|
|
|
test('defaults to off for invalid tier value', () => {
|
|
setConfig('telemetry', 'invalid_value');
|
|
run(`${BIN}/gstack-telemetry-log --skill ship --duration 30 --outcome success --session-id test-789`);
|
|
|
|
expect(readJsonl()).toHaveLength(0);
|
|
});
|
|
|
|
test('includes installation_id for community tier', () => {
|
|
setConfig('telemetry', 'community');
|
|
run(`${BIN}/gstack-telemetry-log --skill review --duration 100 --outcome success --session-id comm-123`);
|
|
|
|
const events = parseJsonl();
|
|
expect(events).toHaveLength(1);
|
|
// installation_id should be a SHA-256 hash (64 hex chars)
|
|
expect(events[0].installation_id).toMatch(/^[a-f0-9]{64}$/);
|
|
});
|
|
|
|
test('installation_id is null for anonymous tier', () => {
|
|
setConfig('telemetry', 'anonymous');
|
|
run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id anon-123`);
|
|
|
|
const events = parseJsonl();
|
|
expect(events[0].installation_id).toBeNull();
|
|
});
|
|
|
|
test('includes error_class when provided', () => {
|
|
setConfig('telemetry', 'anonymous');
|
|
run(`${BIN}/gstack-telemetry-log --skill browse --duration 10 --outcome error --error-class timeout --session-id err-123`);
|
|
|
|
const events = parseJsonl();
|
|
expect(events[0].error_class).toBe('timeout');
|
|
expect(events[0].outcome).toBe('error');
|
|
});
|
|
|
|
test('handles missing duration gracefully', () => {
|
|
setConfig('telemetry', 'anonymous');
|
|
run(`${BIN}/gstack-telemetry-log --skill qa --outcome success --session-id nodur-123`);
|
|
|
|
const events = parseJsonl();
|
|
expect(events[0].duration_s).toBeNull();
|
|
});
|
|
|
|
test('supports event_type flag', () => {
|
|
setConfig('telemetry', 'anonymous');
|
|
run(`${BIN}/gstack-telemetry-log --event-type upgrade_prompted --skill "" --outcome success --session-id up-123`);
|
|
|
|
const events = parseJsonl();
|
|
expect(events[0].event_type).toBe('upgrade_prompted');
|
|
});
|
|
|
|
test('includes local-only fields (_repo_slug, _branch)', () => {
|
|
setConfig('telemetry', 'anonymous');
|
|
run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id local-123`);
|
|
|
|
const events = parseJsonl();
|
|
// These should be present in local JSONL
|
|
expect(events[0]).toHaveProperty('_repo_slug');
|
|
expect(events[0]).toHaveProperty('_branch');
|
|
});
|
|
|
|
test('creates analytics directory if missing', () => {
|
|
// Remove analytics dir
|
|
const analyticsDir = path.join(tmpDir, 'analytics');
|
|
if (fs.existsSync(analyticsDir)) fs.rmSync(analyticsDir, { recursive: true });
|
|
|
|
setConfig('telemetry', 'anonymous');
|
|
run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id mkdir-123`);
|
|
|
|
expect(fs.existsSync(analyticsDir)).toBe(true);
|
|
expect(readJsonl()).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('.pending marker', () => {
|
|
test('finalizes stale .pending from another session as outcome:unknown', () => {
|
|
setConfig('telemetry', 'anonymous');
|
|
|
|
// Write a fake .pending marker from a different session
|
|
const analyticsDir = path.join(tmpDir, 'analytics');
|
|
fs.mkdirSync(analyticsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(analyticsDir, '.pending-old-123'),
|
|
'{"skill":"old-skill","ts":"2026-03-18T00:00:00Z","session_id":"old-123","gstack_version":"0.6.4"}'
|
|
);
|
|
|
|
// Run telemetry-log with a DIFFERENT session — should finalize the old pending marker
|
|
run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id new-456`);
|
|
|
|
const events = parseJsonl();
|
|
expect(events).toHaveLength(2);
|
|
|
|
// First event: finalized pending
|
|
expect(events[0].skill).toBe('old-skill');
|
|
expect(events[0].outcome).toBe('unknown');
|
|
expect(events[0].session_id).toBe('old-123');
|
|
|
|
// Second event: new event
|
|
expect(events[1].skill).toBe('qa');
|
|
expect(events[1].outcome).toBe('success');
|
|
});
|
|
|
|
test('.pending-SESSION file is removed after finalization', () => {
|
|
setConfig('telemetry', 'anonymous');
|
|
|
|
const analyticsDir = path.join(tmpDir, 'analytics');
|
|
fs.mkdirSync(analyticsDir, { recursive: true });
|
|
const pendingPath = path.join(analyticsDir, '.pending-stale-session');
|
|
fs.writeFileSync(pendingPath, '{"skill":"stale","ts":"2026-03-18T00:00:00Z","session_id":"stale-session","gstack_version":"v"}');
|
|
|
|
run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id new-456`);
|
|
|
|
expect(fs.existsSync(pendingPath)).toBe(false);
|
|
});
|
|
|
|
test('does not finalize own session pending marker', () => {
|
|
setConfig('telemetry', 'anonymous');
|
|
|
|
const analyticsDir = path.join(tmpDir, 'analytics');
|
|
fs.mkdirSync(analyticsDir, { recursive: true });
|
|
// Create pending for same session ID we'll use
|
|
const pendingPath = path.join(analyticsDir, '.pending-same-session');
|
|
fs.writeFileSync(pendingPath, '{"skill":"in-flight","ts":"2026-03-18T00:00:00Z","session_id":"same-session","gstack_version":"v"}');
|
|
|
|
run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id same-session`);
|
|
|
|
// Should only have 1 event (the new one), not finalize own pending
|
|
const events = parseJsonl();
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].skill).toBe('qa');
|
|
});
|
|
|
|
test('tier=off still clears own session pending', () => {
|
|
setConfig('telemetry', 'off');
|
|
|
|
const analyticsDir = path.join(tmpDir, 'analytics');
|
|
fs.mkdirSync(analyticsDir, { recursive: true });
|
|
const pendingPath = path.join(analyticsDir, '.pending-off-123');
|
|
fs.writeFileSync(pendingPath, '{"skill":"stale","ts":"2026-03-18T00:00:00Z","session_id":"off-123","gstack_version":"v"}');
|
|
|
|
run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id off-123`);
|
|
|
|
expect(fs.existsSync(pendingPath)).toBe(false);
|
|
// But no JSONL entries since tier=off
|
|
expect(readJsonl()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('gstack-analytics', () => {
|
|
test('shows "no data" for empty JSONL', () => {
|
|
const output = run(`${BIN}/gstack-analytics`);
|
|
expect(output).toContain('no data');
|
|
});
|
|
|
|
test('renders usage dashboard with events', () => {
|
|
setConfig('telemetry', 'anonymous');
|
|
run(`${BIN}/gstack-telemetry-log --skill qa --duration 120 --outcome success --session-id a-1`);
|
|
run(`${BIN}/gstack-telemetry-log --skill qa --duration 60 --outcome success --session-id a-2`);
|
|
run(`${BIN}/gstack-telemetry-log --skill ship --duration 30 --outcome error --error-class timeout --session-id a-3`);
|
|
|
|
const output = run(`${BIN}/gstack-analytics all`);
|
|
expect(output).toContain('/qa');
|
|
expect(output).toContain('/ship');
|
|
expect(output).toContain('2 runs');
|
|
expect(output).toContain('1 runs');
|
|
expect(output).toContain('Success rate: 66%');
|
|
expect(output).toContain('Errors: 1');
|
|
});
|
|
|
|
test('filters by time window', () => {
|
|
setConfig('telemetry', 'anonymous');
|
|
run(`${BIN}/gstack-telemetry-log --skill qa --duration 60 --outcome success --session-id t-1`);
|
|
|
|
const output7d = run(`${BIN}/gstack-analytics 7d`);
|
|
expect(output7d).toContain('/qa');
|
|
expect(output7d).toContain('last 7 days');
|
|
});
|
|
});
|
|
|
|
describe('gstack-telemetry-sync', () => {
|
|
test('exits silently with no endpoint configured', () => {
|
|
// Default: GSTACK_TELEMETRY_ENDPOINT is not set → exit 0
|
|
const result = run(`${BIN}/gstack-telemetry-sync`);
|
|
expect(result).toBe('');
|
|
});
|
|
|
|
test('exits silently with no JSONL file', () => {
|
|
const result = run(`${BIN}/gstack-telemetry-sync`, { GSTACK_TELEMETRY_ENDPOINT: 'http://localhost:9999' });
|
|
expect(result).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('gstack-community-dashboard', () => {
|
|
test('shows unconfigured message when no Supabase config available', () => {
|
|
// Use a fake GSTACK_DIR with no supabase/config.sh
|
|
const output = run(`${BIN}/gstack-community-dashboard`, {
|
|
GSTACK_DIR: tmpDir,
|
|
GSTACK_SUPABASE_URL: '',
|
|
GSTACK_SUPABASE_ANON_KEY: '',
|
|
});
|
|
expect(output).toContain('Supabase not configured');
|
|
expect(output).toContain('gstack-analytics');
|
|
});
|
|
|
|
test('connects to Supabase when config exists', () => {
|
|
// Use the real GSTACK_DIR which has supabase/config.sh
|
|
const output = run(`${BIN}/gstack-community-dashboard`);
|
|
expect(output).toContain('gstack community dashboard');
|
|
// Should not show "not configured" since config.sh exists
|
|
expect(output).not.toContain('Supabase not configured');
|
|
});
|
|
});
|