mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
Bins, careful/freeze hooks, setup, upgrade migrations, browse/src, design, ios-qa daemon, lib and scripts resolve the state root through bin/gstack-state-root.sh (bash) or lib/state-root.ts (TS). Bins source the twin and stop with a reinstall message when it is missing; hooks source it and never spawn gstack-paths. browse/src/config.ts and lib/cso/state.ts delegate to resolveStateRoot. Analytics writers and readers move together so the usage log stays one file. gstack-uninstall deletes state only at ~/.gstack, refuses (exit 2) when it resolves to /, $HOME or an ancestor, the checkout or the git root, and leaves any other resolved root in place with the removal command. Fixtures that copy single bins now copy the twin.
194 lines
6.5 KiB
Bash
Executable File
194 lines
6.5 KiB
Bash
Executable File
#!/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_HOME — relocate the state root (chain: bin/gstack-state-root.sh)
|
|
set -uo pipefail
|
|
|
|
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
|
|
gstack_state_root_select
|
|
STATE_DIR="$_gstack_sr_root"
|
|
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: skill_run events (new format) OR basic skill events (old format, no event_type)
|
|
# Old format: {"skill":"X","ts":"Y","repo":"Z"} (no event_type field)
|
|
# New format: {"event_type":"skill_run","skill":"X","ts":"Y",...}
|
|
FILTERED="$(awk -F'"' -v cutoff="$CUTOFF" '
|
|
/"ts":"/ {
|
|
# Skip hook_fire events
|
|
if (/"event":"hook_fire"/) next
|
|
# Skip non-skill_run new-format events
|
|
if (/"event_type":"/ && !/"event_type":"skill_run"/) next
|
|
for (i=1; i<=NF; i++) {
|
|
if ($i == "ts" && $(i+1) ~ /^:/) {
|
|
ts = $(i+2)
|
|
if (ts >= cutoff) { print; break }
|
|
}
|
|
}
|
|
}
|
|
' "$JSONL_FILE")"
|
|
else
|
|
# All time: include skill_run events + old-format basic events, exclude hook_fire
|
|
FILTERED="$(awk '/"ts":"/ && !/"event":"hook_fire"/' "$JSONL_FILE" | grep -v '"event_type":"upgrade_' 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"' || true)"
|
|
SUCCESS="${SUCCESS:-0}"; SUCCESS="$(echo "$SUCCESS" | tr -d ' \n\r\t')"
|
|
ERRORS="$(echo "$FILTERED" | grep -c '"outcome":"error"' || true)"
|
|
ERRORS="${ERRORS:-0}"; ERRORS="$(echo "$ERRORS" | tr -d ' \n\r\t')"
|
|
# Old format events have no outcome field — count them as successful
|
|
NO_OUTCOME="$(echo "$FILTERED" | grep -vc '"outcome":' || true)"
|
|
NO_OUTCOME="${NO_OUTCOME:-0}"; NO_OUTCOME="$(echo "$NO_OUTCOME" | tr -d ' \n\r\t')"
|
|
SUCCESS=$(( SUCCESS + NO_OUTCOME ))
|
|
|
|
# Calculate success rate
|
|
if [ "$TOTAL" -gt 0 ] 2>/dev/null; then
|
|
SUCCESS_RATE=$(( SUCCESS * 100 / TOTAL ))
|
|
else
|
|
SUCCESS_RATE=100
|
|
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
|
|
TOTAL_DURATION="${TOTAL_DURATION:-0}"
|
|
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"
|