mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
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:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user