feat: telemetry data integrity — source tagging, UUID fingerprint, duration guards

- Add source field (live/test/dev) to telemetry pipeline: --source flag in
  gstack-telemetry-log, GSTACK_TELEMETRY_SOURCE env fallback, pass-through
  in telemetry-sync, source=eq.live filter on all dashboard queries
- Replace SHA-256 installation_id with UUID install_fingerprint for all tiers
  (not just community). Expand-contract migration: ADD new column + trigger
  to copy installation_id, preserving backward compat with old clients
- Fix duration bug: persist _TEL_START to file via $PPID (stable across bash
  blocks), cap durations at 86400s, reject negative values
- Ungate update-check pings from telemetry=off — sends only version + OS +
  random UUID. Generate .install-id in update-check for telemetry=off users
- Migration 003: source columns, install_fingerprint, duration CHECK
  constraint, indexes, recreated views with source filter, growth funnel
  (first-seen based), materialized views for daily installs + version adoption
- E2E test isolation: session-runner sets GSTACK_TELEMETRY_SOURCE=test
- 8 new telemetry tests (source field, duration caps, fingerprint persistence)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-23 15:48:25 -07:00
parent b437b531b7
commit 6bd6d5ba0f
8 changed files with 269 additions and 50 deletions
+9 -14
View File
@@ -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
+28 -15
View File
@@ -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"
+2 -5
View File
@@ -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
+19 -4
View File
@@ -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
+12 -4
View File
@@ -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');
}
@@ -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;
+1 -1
View File
@@ -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
+69 -7
View File
@@ -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`);