mirror of https://github.com/garrytan/gstack.git
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) <noreply@anthropic.com>
This commit is contained in:
parent
bc86a665b7
commit
03d866b8bf
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue