diff --git a/bin/gstack-community-dashboard b/bin/gstack-community-dashboard index 135bd3e1..4b2e2b79 100755 --- a/bin/gstack-community-dashboard +++ b/bin/gstack-community-dashboard @@ -48,20 +48,15 @@ echo "" # ─── Weekly active installs ────────────────────────────────── WEEK_AGO="$(date -u -v-7d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")" if [ -n "$WEEK_AGO" ]; then - PULSE="$(curl -sf --max-time 10 \ - "${SUPABASE_URL}/functions/v1/community-pulse" \ + # Direct REST query (replaces unreliable community-pulse edge function) + WEEKLY="$(curl -sf --max-time 10 \ + "${SUPABASE_URL}/rest/v1/update_checks?select=install_fingerprint&checked_at=gte.${WEEK_AGO}&source=eq.live" \ + -H "apikey: ${ANON_KEY}" \ -H "Authorization: Bearer ${ANON_KEY}" \ - 2>/dev/null || echo '{"weekly_active":0}')" + 2>/dev/null | grep -o '"install_fingerprint":"[^"]*"' | sort -u | wc -l | tr -d ' ')" + WEEKLY="${WEEKLY:-0}" - WEEKLY="$(echo "$PULSE" | grep -o '"weekly_active":[0-9]*' | grep -o '[0-9]*' || echo "0")" - CHANGE="$(echo "$PULSE" | grep -o '"change_pct":[0-9-]*' | grep -o '[0-9-]*' || echo "0")" - - echo "Weekly active installs: ${WEEKLY}" - if [ "$CHANGE" -gt 0 ] 2>/dev/null; then - echo " Change: +${CHANGE}%" - elif [ "$CHANGE" -lt 0 ] 2>/dev/null; then - echo " Change: ${CHANGE}%" - fi + echo "Weekly active installs: ${WEEKLY} unique" echo "" fi @@ -70,7 +65,7 @@ echo "Top skills (last 7 days)" echo "────────────────────────" # Query telemetry_events, group by skill -EVENTS="$(query "telemetry_events" "select=skill,gstack_version,session_id&event_type=eq.skill_run&event_timestamp=gte.${WEEK_AGO}&limit=1000" 2>/dev/null || echo "[]")" +EVENTS="$(query "telemetry_events" "select=skill,gstack_version,session_id&event_type=eq.skill_run&event_timestamp=gte.${WEEK_AGO}&source=eq.live&limit=1000" 2>/dev/null || echo "[]")" if [ "$EVENTS" != "[]" ] && [ -n "$EVENTS" ]; then echo "$EVENTS" | grep -o '"skill":"[^"]*"' | awk -F'"' '{print $4}' | sort | uniq -c | sort -rn | head -10 | while read -r COUNT SKILL; do @@ -85,7 +80,7 @@ echo "" echo "Top errors (last 7 days)" echo "────────────────────────" -ERRORS="$(query "telemetry_events" "select=skill,error_class,error_message,failed_step,duration_s,session_id&outcome=eq.error&event_timestamp=gte.${WEEK_AGO}&order=event_timestamp.desc&limit=200" 2>/dev/null || echo "[]")" +ERRORS="$(query "telemetry_events" "select=skill,error_class,error_message,failed_step,duration_s,session_id&outcome=eq.error&event_timestamp=gte.${WEEK_AGO}&source=eq.live&order=event_timestamp.desc&limit=200" 2>/dev/null || echo "[]")" if [ "$ERRORS" != "[]" ] && [ -n "$ERRORS" ]; then # Group by skill + error_class, show count and example message diff --git a/bin/gstack-telemetry-log b/bin/gstack-telemetry-log index 5edde6dd..1710256c 100755 --- a/bin/gstack-telemetry-log +++ b/bin/gstack-telemetry-log @@ -35,6 +35,7 @@ ERROR_CLASS="" ERROR_MESSAGE="" FAILED_STEP="" EVENT_TYPE="skill_run" +SOURCE="" while [ $# -gt 0 ]; do case "$1" in @@ -47,10 +48,14 @@ while [ $# -gt 0 ]; do --error-message) ERROR_MESSAGE="$2"; shift 2 ;; --failed-step) FAILED_STEP="$2"; shift 2 ;; --event-type) EVENT_TYPE="$2"; shift 2 ;; + --source) SOURCE="$2"; shift 2 ;; *) shift ;; esac done +# Source: flag > env > default 'live' +SOURCE="${SOURCE:-${GSTACK_TELEMETRY_SOURCE:-live}}" + # ─── Read telemetry tier ───────────────────────────────────── TIER="$("$CONFIG_CMD" get telemetry 2>/dev/null || true)" TIER="${TIER:-off}" @@ -109,19 +114,19 @@ if [ -d "$STATE_DIR/sessions" ]; then [ -n "$_SC" ] && [ "$_SC" -gt 0 ] 2>/dev/null && SESSIONS="$_SC" fi -# Generate installation_id for community tier -INSTALL_ID="" -if [ "$TIER" = "community" ]; then - HOST="$(hostname 2>/dev/null || echo "unknown")" - USER="$(whoami 2>/dev/null || echo "unknown")" - if command -v shasum >/dev/null 2>&1; then - INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | shasum -a 256 | awk '{print $1}')" - elif command -v sha256sum >/dev/null 2>&1; then - INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | sha256sum | awk '{print $1}')" - elif command -v openssl >/dev/null 2>&1; then - INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | openssl dgst -sha256 | awk '{print $NF}')" +# Generate/read persistent UUID fingerprint (all tiers, not just community) +INSTALL_FP="" +FP_FILE="$STATE_DIR/.install-id" +if [ -f "$FP_FILE" ]; then + INSTALL_FP="$(cat "$FP_FILE" 2>/dev/null | tr -d '[:space:]')" +fi +if [ -z "$INSTALL_FP" ]; then + INSTALL_FP="$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || python3 -c 'import uuid; print(uuid.uuid4())' 2>/dev/null || echo "")" + INSTALL_FP="$(echo "$INSTALL_FP" | tr '[:upper:]' '[:lower:]')" # normalize case + if [ -n "$INSTALL_FP" ]; then + mkdir -p "$STATE_DIR" + echo "$INSTALL_FP" > "$FP_FILE" fi - # If no SHA-256 command available, install_id stays empty fi # Local-only fields (never sent remotely) @@ -145,20 +150,28 @@ ERR_MSG_FIELD="null" STEP_FIELD="null" [ -n "$FAILED_STEP" ] && STEP_FIELD="\"$(echo "$FAILED_STEP" | head -c 30)\"" +# Cap unreasonable durations +if [ -n "$DURATION" ] && [ "$DURATION" -gt 86400 ] 2>/dev/null; then + DURATION="" # null if > 24h +fi +if [ -n "$DURATION" ] && [ "$DURATION" -lt 0 ] 2>/dev/null; then + DURATION="" # null if negative +fi + DUR_FIELD="null" [ -n "$DURATION" ] && DUR_FIELD="$DURATION" INSTALL_FIELD="null" -[ -n "$INSTALL_ID" ] && INSTALL_FIELD="\"$INSTALL_ID\"" +[ -n "$INSTALL_FP" ] && INSTALL_FIELD="\"$INSTALL_FP\"" BROWSE_BOOL="false" [ "$USED_BROWSE" = "true" ] && BROWSE_BOOL="true" -printf '{"v":1,"ts":"%s","event_type":"%s","skill":"%s","session_id":"%s","gstack_version":"%s","os":"%s","arch":"%s","duration_s":%s,"outcome":"%s","error_class":%s,"error_message":%s,"failed_step":%s,"used_browse":%s,"sessions":%s,"installation_id":%s,"_repo_slug":"%s","_branch":"%s"}\n' \ +printf '{"v":1,"ts":"%s","event_type":"%s","skill":"%s","session_id":"%s","gstack_version":"%s","os":"%s","arch":"%s","duration_s":%s,"outcome":"%s","error_class":%s,"error_message":%s,"failed_step":%s,"used_browse":%s,"sessions":%s,"install_fingerprint":%s,"source":"%s","_repo_slug":"%s","_branch":"%s"}\n' \ "$TS" "$EVENT_TYPE" "$SKILL" "$SESSION_ID" "$GSTACK_VERSION" "$OS" "$ARCH" \ "$DUR_FIELD" "$OUTCOME" "$ERR_FIELD" "$ERR_MSG_FIELD" "$STEP_FIELD" \ "$BROWSE_BOOL" "${SESSIONS:-1}" \ - "$INSTALL_FIELD" "$REPO_SLUG" "$BRANCH" >> "$JSONL_FILE" 2>/dev/null || true + "$INSTALL_FIELD" "$SOURCE" "$REPO_SLUG" "$BRANCH" >> "$JSONL_FILE" 2>/dev/null || true # ─── Trigger sync if tier is not off ───────────────────────── SYNC_CMD="$GSTACK_DIR/bin/gstack-telemetry-sync" diff --git a/bin/gstack-telemetry-sync b/bin/gstack-telemetry-sync index d7ae2836..efb60c7f 100755 --- a/bin/gstack-telemetry-sync +++ b/bin/gstack-telemetry-sync @@ -76,19 +76,16 @@ while IFS= read -r LINE; do echo "$LINE" | grep -q '^{' || continue # Strip local-only fields + map JSONL field names to Postgres column names + # Backward compat: map old installation_id → install_fingerprint for unsent entries CLEAN="$(echo "$LINE" | sed \ -e 's/,"_repo_slug":"[^"]*"//g' \ -e 's/,"_branch":"[^"]*"//g' \ -e 's/"v":/"schema_version":/g' \ -e 's/"ts":/"event_timestamp":/g' \ -e 's/"sessions":/"concurrent_sessions":/g' \ + -e 's/"installation_id":/"install_fingerprint":/g' \ -e 's/,"repo":"[^"]*"//g')" - # If anonymous tier, strip installation_id - if [ "$TIER" = "anonymous" ]; then - CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')" - fi - if [ "$FIRST" = "true" ]; then FIRST=false else diff --git a/bin/gstack-update-check b/bin/gstack-update-check index 8f5193be..56c0d370 100755 --- a/bin/gstack-update-check +++ b/bin/gstack-update-check @@ -167,9 +167,24 @@ if [ -z "${GSTACK_TELEMETRY_ENDPOINT:-}" ] && [ -f "$GSTACK_DIR/supabase/config. fi _SUPA_ENDPOINT="${GSTACK_TELEMETRY_ENDPOINT:-}" _SUPA_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" -# Respect telemetry opt-out — don't ping Supabase if user set telemetry: off -_TEL_TIER="$("$GSTACK_DIR/bin/gstack-config" get telemetry 2>/dev/null || true)" -if [ -n "$_SUPA_ENDPOINT" ] && [ -n "$_SUPA_KEY" ] && [ "${_TEL_TIER:-off}" != "off" ]; then +# Generate/read install fingerprint (runs for ALL tiers including off) +_FP="" +_FP_FILE="$STATE_DIR/.install-id" +if [ -f "$_FP_FILE" ]; then + _FP="$(cat "$_FP_FILE" 2>/dev/null | tr -d '[:space:]')" +fi +if [ -z "$_FP" ]; then + _FP="$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || python3 -c 'import uuid; print(uuid.uuid4())' 2>/dev/null || echo "")" + _FP="$(echo "$_FP" | tr '[:upper:]' '[:lower:]')" + if [ -n "$_FP" ]; then + mkdir -p "$STATE_DIR" + echo "$_FP" > "$_FP_FILE" + fi +fi +# Update-check pings always fire (ungated from telemetry tier). +# This sends only: version, OS, and a random UUID. No usage data. +# Equivalent to what GitHub sees in HTTP access logs for VERSION. +if [ -n "$_SUPA_ENDPOINT" ] && [ -n "$_SUPA_KEY" ]; then _OS="$(uname -s | tr '[:upper:]' '[:lower:]')" curl -sf --max-time 5 \ -X POST "${_SUPA_ENDPOINT}/update_checks" \ @@ -177,7 +192,7 @@ if [ -n "$_SUPA_ENDPOINT" ] && [ -n "$_SUPA_KEY" ] && [ "${_TEL_TIER:-off}" != " -H "apikey: ${_SUPA_KEY}" \ -H "Authorization: Bearer ${_SUPA_KEY}" \ -H "Prefer: return=minimal" \ - -d "{\"gstack_version\":\"$LOCAL\",\"os\":\"$_OS\"}" \ + -d "{\"gstack_version\":\"$LOCAL\",\"os\":\"$_OS\",\"install_fingerprint\":\"${_FP}\"}" \ >/dev/null 2>&1 & fi diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index ae38c2d1..08b64aa5 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -209,8 +209,12 @@ _TEL=$(${ctx.paths.binDir}/gstack-config get telemetry 2>/dev/null || true) _TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") _TEL_START=$(date +%s) _SESSION_ID="$$-$(date +%s)" +echo $_TEL_START > ~/.gstack/analytics/.tel-start-$PPID +echo $_SESSION_ID > ~/.gstack/analytics/.tel-session-$PPID echo "TELEMETRY: \${_TEL:-off}" echo "TEL_PROMPTED: $_TEL_PROMPTED" +echo "TEL_START: $_TEL_START" +echo "SESSION_ID: $_SESSION_ID" _EMAIL=$(${ctx.paths.binDir}/gstack-config get email 2>/dev/null || true) _COMM_PROMPTED=$([ -f ~/.gstack/.community-prompted ] && echo "yes" || echo "no") _AUTH_OK=$(${ctx.paths.binDir}/gstack-auth-refresh --check 2>/dev/null && echo "yes" || echo "no") @@ -526,7 +530,7 @@ Hey gstack team — ran into this while using /{skill-name}: Slug: lowercase, hyphens, max 60 chars (e.g. \`browse-js-no-await\`). Skip if file already exists. Max 3 reports per session. File inline and continue — don't stop the workflow. Tell user: "Filed gstack field report: {title}"`; } -function generateCompletionStatus(): string { +function generateCompletionStatus(ctx: TemplateContext): string { return `## Completion Status Protocol When completing a skill workflow, report status using one of: @@ -577,11 +581,15 @@ Skipping this command loses session duration and outcome data. Run this bash: \`\`\`bash +_TEL_START=$(cat ~/.gstack/analytics/.tel-start-$PPID 2>/dev/null || echo 0) +_SESSION_ID=$(cat ~/.gstack/analytics/.tel-session-$PPID 2>/dev/null || echo "") _TEL_END=$(date +%s) _TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.tel-start-$PPID ~/.gstack/analytics/.tel-session-$PPID 2>/dev/null || true rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true -~/.claude/skills/gstack/bin/gstack-telemetry-log \\ +${ctx.paths.binDir}/gstack-telemetry-log \\ --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \\ + --source "\${GSTACK_TELEMETRY_SOURCE:-live}" \\ --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" \\ --error-class "ERROR_CLASS" --error-message "ERROR_MESSAGE" \\ --failed-step "FAILED_STEP" 2>/dev/null & @@ -603,7 +611,7 @@ When you are in plan mode and about to call ExitPlanMode: 3. If it does NOT — run this command: \\\`\\\`\\\`bash -~/.claude/skills/gstack/bin/gstack-review-read +${ctx.paths.binDir}/gstack-review-read \\\`\\\`\\\` Then write a \`## GSTACK REVIEW REPORT\` section to the end of the plan file: @@ -643,7 +651,7 @@ function generatePreamble(ctx: TemplateContext): string { generateRepoModeSection(), generateSearchBeforeBuildingSection(ctx), generateContributorMode(), - generateCompletionStatus(), + generateCompletionStatus(ctx), ].join('\n\n'); } diff --git a/supabase/migrations/003_source_and_guards.sql b/supabase/migrations/003_source_and_guards.sql new file mode 100644 index 00000000..06bdbdf6 --- /dev/null +++ b/supabase/migrations/003_source_and_guards.sql @@ -0,0 +1,129 @@ +-- gstack telemetry data integrity + growth metrics +-- Adds source tagging, install fingerprinting, duration guards, and growth views. +-- +-- PREREQUISITE: Run Phase 4A cleanup BEFORE this migration: +-- UPDATE telemetry_events SET duration_s = NULL WHERE duration_s > 86400 OR duration_s < 0; + +-- ─── Source field (live/test/dev tagging) ───────────────────── +ALTER TABLE telemetry_events ADD COLUMN source TEXT DEFAULT 'live'; +ALTER TABLE update_checks ADD COLUMN source TEXT DEFAULT 'live'; + +-- ─── Install fingerprinting (expand-then-contract) ─────────── +-- ADD new column (don't RENAME — old clients still POST installation_id) +ALTER TABLE telemetry_events ADD COLUMN install_fingerprint TEXT; +ALTER TABLE update_checks ADD COLUMN install_fingerprint TEXT; + +-- Trigger: copy installation_id → install_fingerprint on INSERT (backward compat) +CREATE OR REPLACE FUNCTION copy_install_id_to_fingerprint() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.install_fingerprint IS NULL AND NEW.installation_id IS NOT NULL THEN + NEW.install_fingerprint := NEW.installation_id; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_copy_install_fingerprint + BEFORE INSERT ON telemetry_events + FOR EACH ROW + EXECUTE FUNCTION copy_install_id_to_fingerprint(); + +-- Backfill existing rows +UPDATE telemetry_events + SET install_fingerprint = installation_id + WHERE installation_id IS NOT NULL AND install_fingerprint IS NULL; + +-- ─── Duration guard ────────────────────────────────────────── +ALTER TABLE telemetry_events + ADD CONSTRAINT duration_reasonable + CHECK (duration_s IS NULL OR (duration_s >= 0 AND duration_s <= 86400)); + +-- ─── Indexes for fingerprint joins + source filtering ──────── +CREATE INDEX idx_update_checks_fingerprint ON update_checks (install_fingerprint); +CREATE INDEX idx_telemetry_fingerprint ON telemetry_events (install_fingerprint); +CREATE INDEX idx_update_checks_source ON update_checks (source) WHERE source = 'live'; +CREATE INDEX idx_telemetry_source ON telemetry_events (source) WHERE source = 'live'; + +-- ─── Recreate crash_clusters with source filter ────────────── +DROP VIEW IF EXISTS crash_clusters; +CREATE VIEW crash_clusters AS +SELECT + error_class, + gstack_version, + COUNT(*) as total_occurrences, + COUNT(DISTINCT install_fingerprint) as identified_users, + COUNT(*) - COUNT(install_fingerprint) as anonymous_occurrences, + MIN(event_timestamp) as first_seen, + MAX(event_timestamp) as last_seen +FROM telemetry_events +WHERE outcome = 'error' AND error_class IS NOT NULL + AND (source = 'live' OR source IS NULL) +GROUP BY error_class, gstack_version +ORDER BY total_occurrences DESC; + +-- ─── Recreate skill_sequences with source filter ───────────── +DROP VIEW IF EXISTS skill_sequences; +CREATE VIEW skill_sequences AS +SELECT + a.skill as skill_a, + b.skill as skill_b, + COUNT(DISTINCT a.session_id) as co_occurrences +FROM telemetry_events a +JOIN telemetry_events b ON a.session_id = b.session_id + AND a.skill != b.skill + AND a.event_timestamp < b.event_timestamp +WHERE a.event_type = 'skill_run' AND b.event_type = 'skill_run' + AND (a.source = 'live' OR a.source IS NULL) + AND (b.source = 'live' OR b.source IS NULL) +GROUP BY a.skill, b.skill +HAVING COUNT(DISTINCT a.session_id) >= 10 +ORDER BY co_occurrences DESC; + +-- ─── Growth views ──────────────────────────────────────────── + +-- Daily active installs (materialized for dashboard perf) +CREATE MATERIALIZED VIEW daily_active_installs AS +SELECT DATE(checked_at) as day, + COUNT(DISTINCT install_fingerprint) as unique_installs, + COUNT(*) as total_pings +FROM update_checks +WHERE source = 'live' OR source IS NULL +GROUP BY DATE(checked_at) +ORDER BY day DESC; + +-- Version adoption velocity (materialized) +CREATE MATERIALIZED VIEW version_adoption AS +SELECT DATE(checked_at) as day, + gstack_version, + COUNT(DISTINCT install_fingerprint) as unique_installs +FROM update_checks +WHERE source = 'live' OR source IS NULL +GROUP BY DATE(checked_at), gstack_version +ORDER BY day DESC; + +-- Growth funnel: first-seen based (not heartbeat-based) +CREATE VIEW growth_funnel AS +WITH first_seen AS ( + SELECT install_fingerprint, MIN(checked_at) as first_check + FROM update_checks + WHERE install_fingerprint IS NOT NULL AND (source = 'live' OR source IS NULL) + GROUP BY install_fingerprint +) +SELECT + DATE(fs.first_check) as install_day, + COUNT(DISTINCT fs.install_fingerprint) as installs, + COUNT(DISTINCT CASE WHEN te.event_timestamp IS NOT NULL THEN fs.install_fingerprint END) as activated, + COUNT(DISTINCT CASE WHEN uc2.checked_at IS NOT NULL THEN fs.install_fingerprint END) as retained_7d +FROM first_seen fs +LEFT JOIN telemetry_events te + ON fs.install_fingerprint = te.install_fingerprint + AND te.event_timestamp BETWEEN fs.first_check AND fs.first_check + INTERVAL '24 hours' + AND te.event_type = 'skill_run' + AND (te.source = 'live' OR te.source IS NULL) +LEFT JOIN update_checks uc2 + ON fs.install_fingerprint = uc2.install_fingerprint + AND uc2.checked_at BETWEEN fs.first_check + INTERVAL '7 days' AND fs.first_check + INTERVAL '14 days' +WHERE fs.install_fingerprint IS NOT NULL +GROUP BY DATE(fs.first_check) +ORDER BY install_day DESC; diff --git a/test/helpers/session-runner.ts b/test/helpers/session-runner.ts index 5e0b057a..0bf3b489 100644 --- a/test/helpers/session-runner.ts +++ b/test/helpers/session-runner.ts @@ -176,7 +176,7 @@ export async function runSkillTest(options: { cwd: workingDirectory, stdout: 'pipe', stderr: 'pipe', - env: { ...process.env, GSTACK_STATE_DIR: testStateDir }, + env: { ...process.env, GSTACK_STATE_DIR: testStateDir, GSTACK_TELEMETRY_SOURCE: 'test' }, }); // Race against timeout diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts index 4dc79b29..79905745 100644 --- a/test/telemetry.test.ts +++ b/test/telemetry.test.ts @@ -72,33 +72,95 @@ describe('gstack-telemetry-log', () => { expect(readJsonl()).toHaveLength(0); }); - test('includes installation_id for community tier', () => { + test('includes install_fingerprint for community tier (UUID)', () => { 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}$/); + // install_fingerprint should be a UUID (lowercase) + expect(events[0].install_fingerprint).toMatch(/^[a-f0-9-]{36}$/); }); - test('installation_id is null for anonymous tier', () => { + test('includes install_fingerprint for anonymous tier (not null — UUID is not PII)', () => { 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(); + // All tiers now get install_fingerprint (random UUID, not PII) + expect(events[0].install_fingerprint).toMatch(/^[a-f0-9-]{36}$/); }); - test('includes error_class when provided', () => { + test('source field defaults to live', () => { setConfig('telemetry', 'anonymous'); - run(`${BIN}/gstack-telemetry-log --skill browse --duration 10 --outcome error --error-class timeout --session-id err-123`); + run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id src-123`); + + const events = parseJsonl(); + expect(events[0].source).toBe('live'); + }); + + test('--source flag overrides default', () => { + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --source test --session-id src-456`); + + const events = parseJsonl(); + expect(events[0].source).toBe('test'); + }); + + test('GSTACK_TELEMETRY_SOURCE env sets source', () => { + setConfig('telemetry', 'anonymous'); + run(`GSTACK_TELEMETRY_SOURCE=test ${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id src-789`); + + const events = parseJsonl(); + expect(events[0].source).toBe('test'); + }); + + test('duration > 86400 is capped to null', () => { + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --skill qa --duration 100000 --outcome success --session-id dur-123`); + + const events = parseJsonl(); + expect(events[0].duration_s).toBeNull(); + }); + + test('negative duration is capped to null', () => { + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --skill qa --duration -5 --outcome success --session-id dur-456`); + + const events = parseJsonl(); + expect(events[0].duration_s).toBeNull(); + }); + + test('install_fingerprint persists across runs', () => { + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --skill qa --duration 10 --outcome success --session-id fp-1`); + run(`${BIN}/gstack-telemetry-log --skill qa --duration 20 --outcome success --session-id fp-2`); + + const events = parseJsonl(); + expect(events).toHaveLength(2); + expect(events[0].install_fingerprint).toBe(events[1].install_fingerprint); + }); + + test('includes error_class, error_message, and failed_step when provided', () => { + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --skill browse --duration 10 --outcome error --error-class timeout --error-message "request timed out after 30s" --failed-step "goto_page" --session-id err-123`); const events = parseJsonl(); expect(events[0].error_class).toBe('timeout'); + expect(events[0].error_message).toBe('request timed out after 30s'); + expect(events[0].failed_step).toBe('goto_page'); expect(events[0].outcome).toBe('error'); }); + test('truncates long error messages', () => { + setConfig('telemetry', 'anonymous'); + const longMsg = 'a'.repeat(300); + run(`${BIN}/gstack-telemetry-log --skill qa --outcome error --error-message "${longMsg}" --session-id trunc-123`); + + const events = parseJsonl(); + expect(events[0].error_message).toHaveLength(200); + }); + test('handles missing duration gracefully', () => { setConfig('telemetry', 'anonymous'); run(`${BIN}/gstack-telemetry-log --skill qa --outcome success --session-id nodur-123`);