#!/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_DIR="$ANALYTICS_DIR" # .pending-* files live here 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 pending markers for this session even if telemetry is off [ -n "$SESSION_ID" ] && rm -f "$PENDING_DIR/.pending-$SESSION_ID" 2>/dev/null || true exit 0 fi # ─── Finalize stale .pending markers ──────────────────────── # Each session gets its own .pending-$SESSION_ID file to avoid races # between concurrent sessions. Finalize any that don't match our session. for PFILE in "$PENDING_DIR"/.pending-*; do [ -f "$PFILE" ] || continue # Skip our own session's marker (it's still in-flight) PFILE_BASE="$(basename "$PFILE")" PFILE_SID="${PFILE_BASE#.pending-}" [ "$PFILE_SID" = "$SESSION_ID" ] && continue PENDING_DATA="$(cat "$PFILE" 2>/dev/null || true)" rm -f "$PFILE" 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 done # Clear our own session's pending marker (we're about to log the real event) [ -n "$SESSION_ID" ] && rm -f "$PENDING_DIR/.pending-$SESSION_ID" 2>/dev/null || true # ─── 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