diff --git a/bin/gstack-community-dashboard b/bin/gstack-community-dashboard index 5b7fc7ec..7d9d48c2 100755 --- a/bin/gstack-community-dashboard +++ b/bin/gstack-community-dashboard @@ -1,7 +1,7 @@ #!/usr/bin/env bash # gstack-community-dashboard — community usage stats from Supabase # -# Queries the Supabase REST API to show community-wide gstack usage: +# Calls the community-pulse edge function for aggregated stats: # skill popularity, crash clusters, version distribution, retention. # # Env overrides (for testing): @@ -30,51 +30,39 @@ if [ -z "$SUPABASE_URL" ] || [ -z "$ANON_KEY" ]; then exit 0 fi -# ─── Helper: query Supabase REST API ───────────────────────── -query() { - local table="$1" - local params="${2:-}" - curl -sf --max-time 10 \ - "${SUPABASE_URL}/rest/v1/${table}?${params}" \ - -H "apikey: ${ANON_KEY}" \ - -H "Authorization: Bearer ${ANON_KEY}" \ - 2>/dev/null || echo "[]" -} +# ─── Fetch aggregated stats from edge function ──────────────── +DATA="$(curl -sf --max-time 15 \ + "${SUPABASE_URL}/functions/v1/community-pulse" \ + -H "apikey: ${ANON_KEY}" \ + 2>/dev/null || echo "{}")" echo "gstack community dashboard" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" # ─── 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 "")" -if [ -n "$WEEK_AGO" ]; then - PULSE="$(curl -sf --max-time 10 \ - "${SUPABASE_URL}/functions/v1/community-pulse" \ - -H "Authorization: Bearer ${ANON_KEY}" \ - 2>/dev/null || echo '{"weekly_active":0}')" +WEEKLY="$(echo "$DATA" | grep -o '"weekly_active":[0-9]*' | grep -o '[0-9]*' || echo "0")" +CHANGE="$(echo "$DATA" | grep -o '"change_pct":[0-9-]*' | grep -o '[0-9-]*' || echo "0")" - WEEKLY="$(echo "$PULSE" | grep -o '"weekly_active":[0-9]*' | grep -o '[0-9]*' || echo "0")" - 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 "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 "" # ─── Skill popularity (top 10) ─────────────────────────────── echo "Top skills (last 7 days)" echo "────────────────────────" -# Query telemetry_events, group by skill -EVENTS="$(query "telemetry_events" "select=skill,gstack_version&event_type=eq.skill_run&event_timestamp=gte.${WEEK_AGO}&limit=1000" 2>/dev/null || echo "[]")" - -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 - printf " /%-20s %d runs\n" "$SKILL" "$COUNT" +# Parse top_skills array from JSON +SKILLS="$(echo "$DATA" | grep -o '"top_skills":\[[^]]*\]' || echo "")" +if [ -n "$SKILLS" ] && [ "$SKILLS" != '"top_skills":[]' ]; then + echo "$SKILLS" | grep -o '"skill":"[^"]*","count":[0-9]*' | while read -r ENTRY; do + SKILL="$(echo "$ENTRY" | grep -o '"skill":"[^"]*"' | awk -F'"' '{print $4}')" + COUNT="$(echo "$ENTRY" | grep -o '"count":[0-9]*' | grep -o '[0-9]*')" + printf " /%-20s %s runs\n" "$SKILL" "$COUNT" done else echo " No data yet" @@ -85,11 +73,11 @@ echo "" echo "Top crash clusters" echo "──────────────────" -CRASHES="$(query "crash_clusters" "select=error_class,gstack_version,total_occurrences,identified_users&limit=5" 2>/dev/null || echo "[]")" - -if [ "$CRASHES" != "[]" ] && [ -n "$CRASHES" ]; then - echo "$CRASHES" | grep -o '"error_class":"[^"]*"' | awk -F'"' '{print $4}' | head -5 | while read -r ERR; do - C="$(echo "$CRASHES" | grep -o "\"error_class\":\"$ERR\"[^}]*\"total_occurrences\":[0-9]*" | grep -o '"total_occurrences":[0-9]*' | head -1 | grep -o '[0-9]*')" +CRASHES="$(echo "$DATA" | grep -o '"crashes":\[[^]]*\]' || echo "")" +if [ -n "$CRASHES" ] && [ "$CRASHES" != '"crashes":[]' ]; then + echo "$CRASHES" | grep -o '"error_class":"[^"]*"[^}]*"total_occurrences":[0-9]*' | head -5 | while read -r ENTRY; do + ERR="$(echo "$ENTRY" | grep -o '"error_class":"[^"]*"' | awk -F'"' '{print $4}')" + C="$(echo "$ENTRY" | grep -o '"total_occurrences":[0-9]*' | grep -o '[0-9]*')" printf " %-30s %s occurrences\n" "$ERR" "${C:-?}" done else @@ -101,9 +89,12 @@ echo "" echo "Version distribution (last 7 days)" echo "───────────────────────────────────" -if [ "$EVENTS" != "[]" ] && [ -n "$EVENTS" ]; then - echo "$EVENTS" | grep -o '"gstack_version":"[^"]*"' | awk -F'"' '{print $4}' | sort | uniq -c | sort -rn | head -5 | while read -r COUNT VER; do - printf " v%-15s %d events\n" "$VER" "$COUNT" +VERSIONS="$(echo "$DATA" | grep -o '"versions":\[[^]]*\]' || echo "")" +if [ -n "$VERSIONS" ] && [ "$VERSIONS" != '"versions":[]' ]; then + echo "$VERSIONS" | grep -o '"version":"[^"]*","count":[0-9]*' | head -5 | while read -r ENTRY; do + VER="$(echo "$ENTRY" | grep -o '"version":"[^"]*"' | awk -F'"' '{print $4}')" + COUNT="$(echo "$ENTRY" | grep -o '"count":[0-9]*' | grep -o '[0-9]*')" + printf " v%-15s %s events\n" "$VER" "$COUNT" done else echo " No data yet" diff --git a/bin/gstack-telemetry-sync b/bin/gstack-telemetry-sync index 90e37243..be767c23 100755 --- a/bin/gstack-telemetry-sync +++ b/bin/gstack-telemetry-sync @@ -3,11 +3,12 @@ # # Fire-and-forget, backgrounded, rate-limited to once per 5 minutes. # Strips local-only fields before sending. Respects privacy tiers. +# Posts to the telemetry-ingest edge function (not PostgREST directly). # # Env overrides (for testing): # GSTACK_STATE_DIR — override ~/.gstack state directory # GSTACK_DIR — override auto-detected gstack root -# GSTACK_TELEMETRY_ENDPOINT — override Supabase endpoint URL +# GSTACK_SUPABASE_URL — override Supabase project URL set -uo pipefail GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" @@ -19,15 +20,15 @@ RATE_FILE="$ANALYTICS_DIR/.last-sync-time" CONFIG_CMD="$GSTACK_DIR/bin/gstack-config" # Source Supabase config if not overridden by env -if [ -z "${GSTACK_TELEMETRY_ENDPOINT:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then +if [ -z "${GSTACK_SUPABASE_URL:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then . "$GSTACK_DIR/supabase/config.sh" fi -ENDPOINT="${GSTACK_TELEMETRY_ENDPOINT:-}" +SUPABASE_URL="${GSTACK_SUPABASE_URL:-}" ANON_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" # ─── Pre-checks ────────────────────────────────────────────── -# No endpoint configured yet → exit silently -[ -z "$ENDPOINT" ] && exit 0 +# No Supabase URL configured yet → exit silently +[ -z "$SUPABASE_URL" ] && exit 0 # No JSONL file → nothing to sync [ -f "$JSONL_FILE" ] || exit 0 @@ -66,6 +67,8 @@ UNSENT="$(tail -n "+$SKIP" "$JSONL_FILE" 2>/dev/null || true)" [ -z "$UNSENT" ] && exit 0 # ─── Strip local-only fields and build batch ───────────────── +# Edge function expects raw JSONL field names (v, ts, sessions) — +# no column renaming needed (the function maps them internally). BATCH="[" FIRST=true COUNT=0 @@ -75,13 +78,10 @@ while IFS= read -r LINE; do [ -z "$LINE" ] && continue echo "$LINE" | grep -q '^{' || continue - # Strip local-only fields + map JSONL field names to Postgres column names + # Strip local-only fields (keep v, ts, sessions as-is for edge function) CLEAN="$(echo "$LINE" | sed \ -e 's/,"_repo_slug":"[^"]*"//g' \ -e 's/,"_branch":"[^"]*"//g' \ - -e 's/"v":/"schema_version":/g' \ - -e 's/"ts":/"event_timestamp":/g' \ - -e 's/"sessions":/"concurrent_sessions":/g' \ -e 's/,"repo":"[^"]*"//g')" # If anonymous tier, strip installation_id @@ -106,21 +106,31 @@ BATCH="$BATCH]" # Nothing to send after filtering [ "$COUNT" -eq 0 ] && exit 0 -# ─── POST to Supabase ──────────────────────────────────────── -HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \ - -X POST "${ENDPOINT}/telemetry_events" \ +# ─── POST to edge function ─────────────────────────────────── +RESP_FILE="$(mktemp /tmp/gstack-sync-XXXXXX 2>/dev/null || echo "/tmp/gstack-sync-$$")" +HTTP_CODE="$(curl -s -w '%{http_code}' --max-time 10 \ + -X POST "${SUPABASE_URL}/functions/v1/telemetry-ingest" \ -H "Content-Type: application/json" \ -H "apikey: ${ANON_KEY}" \ - -H "Authorization: Bearer ${ANON_KEY}" \ - -H "Prefer: return=minimal" \ + -o "$RESP_FILE" \ -d "$BATCH" 2>/dev/null || echo "000")" # ─── Update cursor on success (2xx) ───────────────────────── case "$HTTP_CODE" in - 2*) NEW_CURSOR=$(( CURSOR + COUNT )) - echo "$NEW_CURSOR" > "$CURSOR_FILE" 2>/dev/null || true ;; + 2*) + # Parse inserted count from response — only advance if events were actually inserted. + # Advance by SENT count (not inserted count) because we can't map inserted back to + # source lines. If inserted==0, something is systemically wrong — don't advance. + INSERTED="$(grep -o '"inserted":[0-9]*' "$RESP_FILE" 2>/dev/null | grep -o '[0-9]*' || echo "0")" + if [ "${INSERTED:-0}" -gt 0 ] 2>/dev/null; then + NEW_CURSOR=$(( CURSOR + COUNT )) + echo "$NEW_CURSOR" > "$CURSOR_FILE" 2>/dev/null || true + fi + ;; esac +rm -f "$RESP_FILE" 2>/dev/null || true + # Update rate limit marker touch "$RATE_FILE" 2>/dev/null || true diff --git a/bin/gstack-update-check b/bin/gstack-update-check index 823861d2..7b165468 100755 --- a/bin/gstack-update-check +++ b/bin/gstack-update-check @@ -160,25 +160,22 @@ fi mkdir -p "$STATE_DIR" # Fire Supabase install ping in background (parallel, non-blocking) -# This logs an update check event for community health metrics. -# If the endpoint isn't configured or Supabase is down, this is a no-op. -# Source Supabase config for install ping -if [ -z "${GSTACK_TELEMETRY_ENDPOINT:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then +# This logs an update check event for community health metrics via edge function. +# If Supabase is not configured or telemetry is off, this is a no-op. +if [ -z "${GSTACK_SUPABASE_URL:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then . "$GSTACK_DIR/supabase/config.sh" fi -_SUPA_ENDPOINT="${GSTACK_TELEMETRY_ENDPOINT:-}" +_SUPA_URL="${GSTACK_SUPABASE_URL:-}" _SUPA_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" # Respect telemetry opt-out — don't ping Supabase if user set telemetry: off _TEL_TIER="$("$GSTACK_DIR/bin/gstack-config" get telemetry 2>/dev/null || true)" -if [ -n "$_SUPA_ENDPOINT" ] && [ -n "$_SUPA_KEY" ] && [ "${_TEL_TIER:-off}" != "off" ]; then +if [ -n "$_SUPA_URL" ] && [ -n "$_SUPA_KEY" ] && [ "${_TEL_TIER:-off}" != "off" ]; then _OS="$(uname -s | tr '[:upper:]' '[:lower:]')" curl -sf --max-time 5 \ - -X POST "${_SUPA_ENDPOINT}/update_checks" \ + -X POST "${_SUPA_URL}/functions/v1/update-check" \ -H "Content-Type: application/json" \ -H "apikey: ${_SUPA_KEY}" \ - -H "Authorization: Bearer ${_SUPA_KEY}" \ - -H "Prefer: return=minimal" \ - -d "{\"gstack_version\":\"$LOCAL\",\"os\":\"$_OS\"}" \ + -d "{\"version\":\"$LOCAL\",\"os\":\"$_OS\"}" \ >/dev/null 2>&1 & fi diff --git a/supabase/config.sh b/supabase/config.sh index b10aef6b..bfc739bc 100644 --- a/supabase/config.sh +++ b/supabase/config.sh @@ -1,10 +1,8 @@ #!/usr/bin/env bash # Supabase project config for gstack telemetry # These are PUBLIC keys — safe to commit (like Firebase public config). -# RLS policies restrict what the anon/publishable key can do (INSERT only). +# RLS denies all access to the anon key. All reads and writes go through +# edge functions (which use SUPABASE_SERVICE_ROLE_KEY server-side). GSTACK_SUPABASE_URL="https://frugpmstpnojnhfyimgv.supabase.co" GSTACK_SUPABASE_ANON_KEY="sb_publishable_tR4i6cyMIrYTE3s6OyHGHw_ppx2p6WK" - -# Telemetry ingest endpoint (Data API) -GSTACK_TELEMETRY_ENDPOINT="${GSTACK_SUPABASE_URL}/rest/v1"