mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-19 10:52:28 +08: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 ──────────────────────────────────
|
# ─── 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 "")"
|
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
|
if [ -n "$WEEK_AGO" ]; then
|
||||||
PULSE="$(curl -sf --max-time 10 \
|
# Direct REST query (replaces unreliable community-pulse edge function)
|
||||||
"${SUPABASE_URL}/functions/v1/community-pulse" \
|
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}" \
|
-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")"
|
echo "Weekly active installs: ${WEEKLY} unique"
|
||||||
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 ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -70,7 +65,7 @@ echo "Top skills (last 7 days)"
|
|||||||
echo "────────────────────────"
|
echo "────────────────────────"
|
||||||
|
|
||||||
# Query telemetry_events, group by skill
|
# 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
|
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
|
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 "Top errors (last 7 days)"
|
||||||
echo "────────────────────────"
|
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
|
if [ "$ERRORS" != "[]" ] && [ -n "$ERRORS" ]; then
|
||||||
# Group by skill + error_class, show count and example message
|
# Group by skill + error_class, show count and example message
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ ERROR_CLASS=""
|
|||||||
ERROR_MESSAGE=""
|
ERROR_MESSAGE=""
|
||||||
FAILED_STEP=""
|
FAILED_STEP=""
|
||||||
EVENT_TYPE="skill_run"
|
EVENT_TYPE="skill_run"
|
||||||
|
SOURCE=""
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -47,10 +48,14 @@ while [ $# -gt 0 ]; do
|
|||||||
--error-message) ERROR_MESSAGE="$2"; shift 2 ;;
|
--error-message) ERROR_MESSAGE="$2"; shift 2 ;;
|
||||||
--failed-step) FAILED_STEP="$2"; shift 2 ;;
|
--failed-step) FAILED_STEP="$2"; shift 2 ;;
|
||||||
--event-type) EVENT_TYPE="$2"; shift 2 ;;
|
--event-type) EVENT_TYPE="$2"; shift 2 ;;
|
||||||
|
--source) SOURCE="$2"; shift 2 ;;
|
||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Source: flag > env > default 'live'
|
||||||
|
SOURCE="${SOURCE:-${GSTACK_TELEMETRY_SOURCE:-live}}"
|
||||||
|
|
||||||
# ─── Read telemetry tier ─────────────────────────────────────
|
# ─── Read telemetry tier ─────────────────────────────────────
|
||||||
TIER="$("$CONFIG_CMD" get telemetry 2>/dev/null || true)"
|
TIER="$("$CONFIG_CMD" get telemetry 2>/dev/null || true)"
|
||||||
TIER="${TIER:-off}"
|
TIER="${TIER:-off}"
|
||||||
@@ -109,19 +114,19 @@ if [ -d "$STATE_DIR/sessions" ]; then
|
|||||||
[ -n "$_SC" ] && [ "$_SC" -gt 0 ] 2>/dev/null && SESSIONS="$_SC"
|
[ -n "$_SC" ] && [ "$_SC" -gt 0 ] 2>/dev/null && SESSIONS="$_SC"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate installation_id for community tier
|
# Generate/read persistent UUID fingerprint (all tiers, not just community)
|
||||||
INSTALL_ID=""
|
INSTALL_FP=""
|
||||||
if [ "$TIER" = "community" ]; then
|
FP_FILE="$STATE_DIR/.install-id"
|
||||||
HOST="$(hostname 2>/dev/null || echo "unknown")"
|
if [ -f "$FP_FILE" ]; then
|
||||||
USER="$(whoami 2>/dev/null || echo "unknown")"
|
INSTALL_FP="$(cat "$FP_FILE" 2>/dev/null | tr -d '[:space:]')"
|
||||||
if command -v shasum >/dev/null 2>&1; then
|
fi
|
||||||
INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | shasum -a 256 | awk '{print $1}')"
|
if [ -z "$INSTALL_FP" ]; then
|
||||||
elif command -v sha256sum >/dev/null 2>&1; 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_ID="$(printf '%s-%s' "$HOST" "$USER" | sha256sum | awk '{print $1}')"
|
INSTALL_FP="$(echo "$INSTALL_FP" | tr '[:upper:]' '[:lower:]')" # normalize case
|
||||||
elif command -v openssl >/dev/null 2>&1; then
|
if [ -n "$INSTALL_FP" ]; then
|
||||||
INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | openssl dgst -sha256 | awk '{print $NF}')"
|
mkdir -p "$STATE_DIR"
|
||||||
|
echo "$INSTALL_FP" > "$FP_FILE"
|
||||||
fi
|
fi
|
||||||
# If no SHA-256 command available, install_id stays empty
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Local-only fields (never sent remotely)
|
# Local-only fields (never sent remotely)
|
||||||
@@ -145,20 +150,28 @@ ERR_MSG_FIELD="null"
|
|||||||
STEP_FIELD="null"
|
STEP_FIELD="null"
|
||||||
[ -n "$FAILED_STEP" ] && STEP_FIELD="\"$(echo "$FAILED_STEP" | head -c 30)\""
|
[ -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"
|
DUR_FIELD="null"
|
||||||
[ -n "$DURATION" ] && DUR_FIELD="$DURATION"
|
[ -n "$DURATION" ] && DUR_FIELD="$DURATION"
|
||||||
|
|
||||||
INSTALL_FIELD="null"
|
INSTALL_FIELD="null"
|
||||||
[ -n "$INSTALL_ID" ] && INSTALL_FIELD="\"$INSTALL_ID\""
|
[ -n "$INSTALL_FP" ] && INSTALL_FIELD="\"$INSTALL_FP\""
|
||||||
|
|
||||||
BROWSE_BOOL="false"
|
BROWSE_BOOL="false"
|
||||||
[ "$USED_BROWSE" = "true" ] && BROWSE_BOOL="true"
|
[ "$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" \
|
"$TS" "$EVENT_TYPE" "$SKILL" "$SESSION_ID" "$GSTACK_VERSION" "$OS" "$ARCH" \
|
||||||
"$DUR_FIELD" "$OUTCOME" "$ERR_FIELD" "$ERR_MSG_FIELD" "$STEP_FIELD" \
|
"$DUR_FIELD" "$OUTCOME" "$ERR_FIELD" "$ERR_MSG_FIELD" "$STEP_FIELD" \
|
||||||
"$BROWSE_BOOL" "${SESSIONS:-1}" \
|
"$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 ─────────────────────────
|
# ─── Trigger sync if tier is not off ─────────────────────────
|
||||||
SYNC_CMD="$GSTACK_DIR/bin/gstack-telemetry-sync"
|
SYNC_CMD="$GSTACK_DIR/bin/gstack-telemetry-sync"
|
||||||
|
|||||||
@@ -76,19 +76,16 @@ while IFS= read -r LINE; do
|
|||||||
echo "$LINE" | grep -q '^{' || continue
|
echo "$LINE" | grep -q '^{' || continue
|
||||||
|
|
||||||
# Strip local-only fields + map JSONL field names to Postgres column names
|
# 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 \
|
CLEAN="$(echo "$LINE" | sed \
|
||||||
-e 's/,"_repo_slug":"[^"]*"//g' \
|
-e 's/,"_repo_slug":"[^"]*"//g' \
|
||||||
-e 's/,"_branch":"[^"]*"//g' \
|
-e 's/,"_branch":"[^"]*"//g' \
|
||||||
-e 's/"v":/"schema_version":/g' \
|
-e 's/"v":/"schema_version":/g' \
|
||||||
-e 's/"ts":/"event_timestamp":/g' \
|
-e 's/"ts":/"event_timestamp":/g' \
|
||||||
-e 's/"sessions":/"concurrent_sessions":/g' \
|
-e 's/"sessions":/"concurrent_sessions":/g' \
|
||||||
|
-e 's/"installation_id":/"install_fingerprint":/g' \
|
||||||
-e 's/,"repo":"[^"]*"//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
|
if [ "$FIRST" = "true" ]; then
|
||||||
FIRST=false
|
FIRST=false
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -167,9 +167,24 @@ if [ -z "${GSTACK_TELEMETRY_ENDPOINT:-}" ] && [ -f "$GSTACK_DIR/supabase/config.
|
|||||||
fi
|
fi
|
||||||
_SUPA_ENDPOINT="${GSTACK_TELEMETRY_ENDPOINT:-}"
|
_SUPA_ENDPOINT="${GSTACK_TELEMETRY_ENDPOINT:-}"
|
||||||
_SUPA_KEY="${GSTACK_SUPABASE_ANON_KEY:-}"
|
_SUPA_KEY="${GSTACK_SUPABASE_ANON_KEY:-}"
|
||||||
# Respect telemetry opt-out — don't ping Supabase if user set telemetry: off
|
# Generate/read install fingerprint (runs for ALL tiers including off)
|
||||||
_TEL_TIER="$("$GSTACK_DIR/bin/gstack-config" get telemetry 2>/dev/null || true)"
|
_FP=""
|
||||||
if [ -n "$_SUPA_ENDPOINT" ] && [ -n "$_SUPA_KEY" ] && [ "${_TEL_TIER:-off}" != "off" ]; then
|
_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:]')"
|
_OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||||
curl -sf --max-time 5 \
|
curl -sf --max-time 5 \
|
||||||
-X POST "${_SUPA_ENDPOINT}/update_checks" \
|
-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 "apikey: ${_SUPA_KEY}" \
|
||||||
-H "Authorization: Bearer ${_SUPA_KEY}" \
|
-H "Authorization: Bearer ${_SUPA_KEY}" \
|
||||||
-H "Prefer: return=minimal" \
|
-H "Prefer: return=minimal" \
|
||||||
-d "{\"gstack_version\":\"$LOCAL\",\"os\":\"$_OS\"}" \
|
-d "{\"gstack_version\":\"$LOCAL\",\"os\":\"$_OS\",\"install_fingerprint\":\"${_FP}\"}" \
|
||||||
>/dev/null 2>&1 &
|
>/dev/null 2>&1 &
|
||||||
fi
|
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_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no")
|
||||||
_TEL_START=$(date +%s)
|
_TEL_START=$(date +%s)
|
||||||
_SESSION_ID="$$-$(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 "TELEMETRY: \${_TEL:-off}"
|
||||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
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)
|
_EMAIL=$(${ctx.paths.binDir}/gstack-config get email 2>/dev/null || true)
|
||||||
_COMM_PROMPTED=$([ -f ~/.gstack/.community-prompted ] && echo "yes" || echo "no")
|
_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")
|
_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}"`;
|
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
|
return `## Completion Status Protocol
|
||||||
|
|
||||||
When completing a skill workflow, report status using one of:
|
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:
|
Run this bash:
|
||||||
|
|
||||||
\`\`\`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_END=$(date +%s)
|
||||||
_TEL_DUR=$(( _TEL_END - _TEL_START ))
|
_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
|
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" \\
|
--skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \\
|
||||||
|
--source "\${GSTACK_TELEMETRY_SOURCE:-live}" \\
|
||||||
--used-browse "USED_BROWSE" --session-id "$_SESSION_ID" \\
|
--used-browse "USED_BROWSE" --session-id "$_SESSION_ID" \\
|
||||||
--error-class "ERROR_CLASS" --error-message "ERROR_MESSAGE" \\
|
--error-class "ERROR_CLASS" --error-message "ERROR_MESSAGE" \\
|
||||||
--failed-step "FAILED_STEP" 2>/dev/null &
|
--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:
|
3. If it does NOT — run this command:
|
||||||
|
|
||||||
\\\`\\\`\\\`bash
|
\\\`\\\`\\\`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:
|
Then write a \`## GSTACK REVIEW REPORT\` section to the end of the plan file:
|
||||||
@@ -643,7 +651,7 @@ function generatePreamble(ctx: TemplateContext): string {
|
|||||||
generateRepoModeSection(),
|
generateRepoModeSection(),
|
||||||
generateSearchBeforeBuildingSection(ctx),
|
generateSearchBeforeBuildingSection(ctx),
|
||||||
generateContributorMode(),
|
generateContributorMode(),
|
||||||
generateCompletionStatus(),
|
generateCompletionStatus(ctx),
|
||||||
].join('\n\n');
|
].join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
supabase/migrations/003_source_and_guards.sql
Normal file
129
supabase/migrations/003_source_and_guards.sql
Normal file
@@ -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,
|
cwd: workingDirectory,
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
env: { ...process.env, GSTACK_STATE_DIR: testStateDir },
|
env: { ...process.env, GSTACK_STATE_DIR: testStateDir, GSTACK_TELEMETRY_SOURCE: 'test' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Race against timeout
|
// Race against timeout
|
||||||
|
|||||||
@@ -72,33 +72,95 @@ describe('gstack-telemetry-log', () => {
|
|||||||
expect(readJsonl()).toHaveLength(0);
|
expect(readJsonl()).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('includes installation_id for community tier', () => {
|
test('includes install_fingerprint for community tier (UUID)', () => {
|
||||||
setConfig('telemetry', 'community');
|
setConfig('telemetry', 'community');
|
||||||
run(`${BIN}/gstack-telemetry-log --skill review --duration 100 --outcome success --session-id comm-123`);
|
run(`${BIN}/gstack-telemetry-log --skill review --duration 100 --outcome success --session-id comm-123`);
|
||||||
|
|
||||||
const events = parseJsonl();
|
const events = parseJsonl();
|
||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
// installation_id should be a SHA-256 hash (64 hex chars)
|
// install_fingerprint should be a UUID (lowercase)
|
||||||
expect(events[0].installation_id).toMatch(/^[a-f0-9]{64}$/);
|
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');
|
setConfig('telemetry', 'anonymous');
|
||||||
run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id anon-123`);
|
run(`${BIN}/gstack-telemetry-log --skill qa --duration 50 --outcome success --session-id anon-123`);
|
||||||
|
|
||||||
const events = parseJsonl();
|
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');
|
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();
|
const events = parseJsonl();
|
||||||
expect(events[0].error_class).toBe('timeout');
|
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');
|
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', () => {
|
test('handles missing duration gracefully', () => {
|
||||||
setConfig('telemetry', 'anonymous');
|
setConfig('telemetry', 'anonymous');
|
||||||
run(`${BIN}/gstack-telemetry-log --skill qa --outcome success --session-id nodur-123`);
|
run(`${BIN}/gstack-telemetry-log --skill qa --outcome success --session-id nodur-123`);
|
||||||
|
|||||||
Reference in New Issue
Block a user