From 03d866b8bfd535195c4e768162245cddea619662 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 18 Mar 2026 23:58:15 -0700 Subject: [PATCH] feat: add gstack-telemetry-log and gstack-analytics scripts Local telemetry infrastructure for gstack usage tracking. gstack-telemetry-log appends JSONL events with skill name, duration, outcome, session ID, and platform info. Supports off/anonymous/community privacy tiers. gstack-analytics renders a personal usage dashboard from local data. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/gstack-analytics | 177 +++++++++++++++++++++++++++++++++++++++ bin/gstack-telemetry-log | 148 ++++++++++++++++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100755 bin/gstack-analytics create mode 100755 bin/gstack-telemetry-log diff --git a/bin/gstack-analytics b/bin/gstack-analytics new file mode 100755 index 00000000..548da25b --- /dev/null +++ b/bin/gstack-analytics @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# gstack-analytics — personal usage dashboard from local JSONL +# +# Usage: +# gstack-analytics # default: last 7 days +# gstack-analytics 7d # last 7 days +# gstack-analytics 30d # last 30 days +# gstack-analytics all # all time +# +# Env overrides (for testing): +# GSTACK_STATE_DIR — override ~/.gstack state directory +set -uo pipefail + +STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +JSONL_FILE="$STATE_DIR/analytics/skill-usage.jsonl" + +# ─── Parse time window ─────────────────────────────────────── +WINDOW="${1:-7d}" +case "$WINDOW" in + 7d) DAYS=7; LABEL="last 7 days" ;; + 30d) DAYS=30; LABEL="last 30 days" ;; + all) DAYS=0; LABEL="all time" ;; + *) DAYS=7; LABEL="last 7 days" ;; +esac + +# ─── Check for data ────────────────────────────────────────── +if [ ! -f "$JSONL_FILE" ]; then + echo "gstack usage — no data yet" + echo "" + echo "Usage data will appear here after you use gstack skills" + echo "with telemetry enabled (gstack-config set telemetry anonymous)." + exit 0 +fi + +TOTAL_LINES="$(wc -l < "$JSONL_FILE" | tr -d ' ')" +if [ "$TOTAL_LINES" = "0" ]; then + echo "gstack usage — no data yet" + exit 0 +fi + +# ─── Filter by time window ─────────────────────────────────── +if [ "$DAYS" -gt 0 ] 2>/dev/null; then + # Calculate cutoff date + if date -v-1d +%Y-%m-%d >/dev/null 2>&1; then + # macOS date + CUTOFF="$(date -v-${DAYS}d -u +%Y-%m-%dT%H:%M:%SZ)" + else + # GNU date + CUTOFF="$(date -u -d "$DAYS days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "2000-01-01T00:00:00Z")" + fi + # Filter: only skill_run events with ts >= cutoff + FILTERED="$(awk -F'"' -v cutoff="$CUTOFF" ' + /"event_type":"skill_run"/ && /"ts":"/ { + for (i=1; i<=NF; i++) { + if ($i == "ts" && $(i+1) ~ /^:/) { + ts = $(i+2) + if (ts >= cutoff) { print; break } + } + } + } + ' "$JSONL_FILE")" +else + FILTERED="$(grep '"event_type":"skill_run"' "$JSONL_FILE" 2>/dev/null || true)" +fi + +if [ -z "$FILTERED" ]; then + echo "gstack usage ($LABEL) — no skill runs found" + exit 0 +fi + +# ─── Aggregate by skill ────────────────────────────────────── +# Extract skill names and count +SKILL_COUNTS="$(echo "$FILTERED" | awk -F'"' ' + /"skill":"/ { + for (i=1; i<=NF; i++) { + if ($i == "skill" && $(i+1) ~ /^:/) { + skill = $(i+2) + counts[skill]++ + break + } + } + } + END { + for (s in counts) print counts[s], s + } +' | sort -rn)" + +# Count outcomes +TOTAL="$(echo "$FILTERED" | wc -l | tr -d ' ')" +SUCCESS="$(echo "$FILTERED" | grep -c '"outcome":"success"' 2>/dev/null || echo "0")" +ERRORS="$(echo "$FILTERED" | grep -c '"outcome":"error"' 2>/dev/null || echo "0")" + +# Calculate success rate +if [ "$TOTAL" -gt 0 ]; then + SUCCESS_RATE=$(( SUCCESS * 100 / TOTAL )) +else + SUCCESS_RATE=0 +fi + +# ─── Calculate total duration ──────────────────────────────── +TOTAL_DURATION="$(echo "$FILTERED" | awk -F'[:,]' ' + /"duration_s"/ { + for (i=1; i<=NF; i++) { + if ($i ~ /"duration_s"/) { + val = $(i+1) + gsub(/[^0-9.]/, "", val) + if (val+0 > 0) total += val + } + } + } + END { printf "%.0f", total } +')" + +# Format duration +if [ "$TOTAL_DURATION" -ge 3600 ] 2>/dev/null; then + HOURS=$(( TOTAL_DURATION / 3600 )) + MINS=$(( (TOTAL_DURATION % 3600) / 60 )) + DUR_DISPLAY="${HOURS}h ${MINS}m" +elif [ "$TOTAL_DURATION" -ge 60 ] 2>/dev/null; then + MINS=$(( TOTAL_DURATION / 60 )) + DUR_DISPLAY="${MINS}m" +else + DUR_DISPLAY="${TOTAL_DURATION}s" +fi + +# ─── Render output ─────────────────────────────────────────── +echo "gstack usage ($LABEL)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# Find max count for bar scaling +MAX_COUNT="$(echo "$SKILL_COUNTS" | head -1 | awk '{print $1}')" +BAR_WIDTH=20 + +echo "$SKILL_COUNTS" | while read -r COUNT SKILL; do + # Scale bar + if [ "$MAX_COUNT" -gt 0 ] 2>/dev/null; then + BAR_LEN=$(( COUNT * BAR_WIDTH / MAX_COUNT )) + else + BAR_LEN=1 + fi + [ "$BAR_LEN" -lt 1 ] && BAR_LEN=1 + + # Build bar + BAR="" + i=0 + while [ "$i" -lt "$BAR_LEN" ]; do + BAR="${BAR}█" + i=$(( i + 1 )) + done + + # Calculate avg duration for this skill + AVG_DUR="$(echo "$FILTERED" | awk -v skill="$SKILL" ' + index($0, "\"skill\":\"" skill "\"") > 0 { + # Extract duration_s value using split on "duration_s": + n = split($0, parts, "\"duration_s\":") + if (n >= 2) { + # parts[2] starts with the value, e.g. "142," + gsub(/[^0-9.].*/, "", parts[2]) + if (parts[2]+0 > 0) { total += parts[2]; count++ } + } + } + END { if (count > 0) printf "%.0f", total/count; else print "0" } + ')" + + # Format avg duration + if [ "$AVG_DUR" -ge 60 ] 2>/dev/null; then + AVG_DISPLAY="$(( AVG_DUR / 60 ))m" + else + AVG_DISPLAY="${AVG_DUR}s" + fi + + printf " /%-20s %s %d runs (avg %s)\n" "$SKILL" "$BAR" "$COUNT" "$AVG_DISPLAY" +done + +echo "" +echo "Success rate: ${SUCCESS_RATE}% | Errors: ${ERRORS} | Total time: ${DUR_DISPLAY}" +echo "Events: ${TOTAL} skill runs" diff --git a/bin/gstack-telemetry-log b/bin/gstack-telemetry-log new file mode 100755 index 00000000..d8ea84d6 --- /dev/null +++ b/bin/gstack-telemetry-log @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# gstack-telemetry-log — append a telemetry event to local JSONL +# +# Data flow: +# preamble (start) ──▶ .pending marker +# preamble (epilogue) ──▶ gstack-telemetry-log ──▶ skill-usage.jsonl +# └──▶ gstack-telemetry-sync (bg) +# +# Usage: +# gstack-telemetry-log --skill qa --duration 142 --outcome success \ +# --used-browse true --session-id "12345-1710756600" +# +# Env overrides (for testing): +# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_DIR — override auto-detected gstack root +# +# NOTE: Uses set -uo pipefail (no -e) — telemetry must never exit non-zero +set -uo pipefail + +GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +ANALYTICS_DIR="$STATE_DIR/analytics" +JSONL_FILE="$ANALYTICS_DIR/skill-usage.jsonl" +PENDING_FILE="$ANALYTICS_DIR/.pending" +CONFIG_CMD="$GSTACK_DIR/bin/gstack-config" +VERSION_FILE="$GSTACK_DIR/VERSION" + +# ─── Parse flags ───────────────────────────────────────────── +SKILL="" +DURATION="" +OUTCOME="unknown" +USED_BROWSE="false" +SESSION_ID="" +ERROR_CLASS="" +EVENT_TYPE="skill_run" + +while [ $# -gt 0 ]; do + case "$1" in + --skill) SKILL="$2"; shift 2 ;; + --duration) DURATION="$2"; shift 2 ;; + --outcome) OUTCOME="$2"; shift 2 ;; + --used-browse) USED_BROWSE="$2"; shift 2 ;; + --session-id) SESSION_ID="$2"; shift 2 ;; + --error-class) ERROR_CLASS="$2"; shift 2 ;; + --event-type) EVENT_TYPE="$2"; shift 2 ;; + *) shift ;; + esac +done + +# ─── Read telemetry tier ───────────────────────────────────── +TIER="$("$CONFIG_CMD" get telemetry 2>/dev/null || true)" +TIER="${TIER:-off}" + +# Validate tier +case "$TIER" in + off|anonymous|community) ;; + *) TIER="off" ;; # invalid value → default to off +esac + +if [ "$TIER" = "off" ]; then + # Still clear any pending marker even if telemetry is off + rm -f "$PENDING_FILE" 2>/dev/null || true + exit 0 +fi + +# ─── Finalize stale .pending marker ───────────────────────── +if [ -f "$PENDING_FILE" ]; then + # .pending contains a JSON fragment: {"skill":"X","ts":"Y","session_id":"Z","gstack_version":"V"} + PENDING_DATA="$(cat "$PENDING_FILE" 2>/dev/null || true)" + rm -f "$PENDING_FILE" 2>/dev/null || true + if [ -n "$PENDING_DATA" ]; then + # Extract fields from pending marker using grep -o + awk + P_SKILL="$(echo "$PENDING_DATA" | grep -o '"skill":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" + P_TS="$(echo "$PENDING_DATA" | grep -o '"ts":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" + P_SID="$(echo "$PENDING_DATA" | grep -o '"session_id":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" + P_VER="$(echo "$PENDING_DATA" | grep -o '"gstack_version":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" + P_OS="$(uname -s | tr '[:upper:]' '[:lower:]')" + P_ARCH="$(uname -m)" + + # Write the stale event as outcome: unknown + mkdir -p "$ANALYTICS_DIR" + printf '{"v":1,"ts":"%s","event_type":"skill_run","skill":"%s","session_id":"%s","gstack_version":"%s","os":"%s","arch":"%s","duration_s":null,"outcome":"unknown","error_class":null,"used_browse":false,"sessions":1}\n' \ + "$P_TS" "$P_SKILL" "$P_SID" "$P_VER" "$P_OS" "$P_ARCH" >> "$JSONL_FILE" 2>/dev/null || true + fi +fi + +# ─── Collect metadata ──────────────────────────────────────── +TS="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%S 2>/dev/null || echo "")" +GSTACK_VERSION="$(cat "$VERSION_FILE" 2>/dev/null | tr -d '[:space:]' || echo "unknown")" +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +SESSIONS="1" +if [ -d "$STATE_DIR/sessions" ]; then + _SC="$(find "$STATE_DIR/sessions" -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' \n\r\t')" + [ -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}')" + fi + # If no SHA-256 command available, install_id stays empty +fi + +# Local-only fields (never sent remotely) +REPO_SLUG="" +BRANCH="" +if command -v git >/dev/null 2>&1; then + REPO_SLUG="$(git remote get-url origin 2>/dev/null | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-' 2>/dev/null || true)" + BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +fi + +# ─── Construct and append JSON ─────────────────────────────── +mkdir -p "$ANALYTICS_DIR" + +# Escape null fields +ERR_FIELD="null" +[ -n "$ERROR_CLASS" ] && ERR_FIELD="\"$ERROR_CLASS\"" + +DUR_FIELD="null" +[ -n "$DURATION" ] && DUR_FIELD="$DURATION" + +INSTALL_FIELD="null" +[ -n "$INSTALL_ID" ] && INSTALL_FIELD="\"$INSTALL_ID\"" + +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,"used_browse":%s,"sessions":%s,"installation_id":%s,"_repo_slug":"%s","_branch":"%s"}\n' \ + "$TS" "$EVENT_TYPE" "$SKILL" "$SESSION_ID" "$GSTACK_VERSION" "$OS" "$ARCH" \ + "$DUR_FIELD" "$OUTCOME" "$ERR_FIELD" "$BROWSE_BOOL" "${SESSIONS:-1}" \ + "$INSTALL_FIELD" "$REPO_SLUG" "$BRANCH" >> "$JSONL_FILE" 2>/dev/null || true + +# ─── Trigger sync if tier is not off ───────────────────────── +SYNC_CMD="$GSTACK_DIR/bin/gstack-telemetry-sync" +if [ -x "$SYNC_CMD" ]; then + "$SYNC_CMD" 2>/dev/null & +fi + +exit 0