fix: route all telemetry through edge functions, not PostgREST

- gstack-telemetry-sync: POST to /functions/v1/telemetry-ingest instead of
  /rest/v1/telemetry_events. Removes sed field-renaming (edge function expects
  raw JSONL names). Parses inserted count — holds cursor if zero inserted.
- gstack-update-check: POST to /functions/v1/update-check.
- gstack-community-dashboard: calls community-pulse edge function instead of
  direct PostgREST queries.
- config.sh: removes GSTACK_TELEMETRY_ENDPOINT, fixes misleading comment.
This commit is contained in:
Garry Tan 2026-03-24 14:20:04 -07:00
parent 54d5e69e69
commit 12d3a6a18c
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
4 changed files with 67 additions and 71 deletions

View File

@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gstack-community-dashboard — community usage stats from Supabase # 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. # skill popularity, crash clusters, version distribution, retention.
# #
# Env overrides (for testing): # Env overrides (for testing):
@ -30,51 +30,39 @@ if [ -z "$SUPABASE_URL" ] || [ -z "$ANON_KEY" ]; then
exit 0 exit 0
fi fi
# ─── Helper: query Supabase REST API ───────────────────────── # ─── Fetch aggregated stats from edge function ────────────────
query() { DATA="$(curl -sf --max-time 15 \
local table="$1" "${SUPABASE_URL}/functions/v1/community-pulse" \
local params="${2:-}" -H "apikey: ${ANON_KEY}" \
curl -sf --max-time 10 \ 2>/dev/null || echo "{}")"
"${SUPABASE_URL}/rest/v1/${table}?${params}" \
-H "apikey: ${ANON_KEY}" \
-H "Authorization: Bearer ${ANON_KEY}" \
2>/dev/null || echo "[]"
}
echo "gstack community dashboard" echo "gstack community dashboard"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "" echo ""
# ─── Weekly active installs ────────────────────────────────── # ─── 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 "")" WEEKLY="$(echo "$DATA" | grep -o '"weekly_active":[0-9]*' | grep -o '[0-9]*' || echo "0")"
if [ -n "$WEEK_AGO" ]; then CHANGE="$(echo "$DATA" | grep -o '"change_pct":[0-9-]*' | grep -o '[0-9-]*' || echo "0")"
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 "$PULSE" | grep -o '"weekly_active":[0-9]*' | grep -o '[0-9]*' || echo "0")" echo "Weekly active installs: ${WEEKLY}"
CHANGE="$(echo "$PULSE" | grep -o '"change_pct":[0-9-]*' | grep -o '[0-9-]*' || echo "0")" if [ "$CHANGE" -gt 0 ] 2>/dev/null; then
echo " Change: +${CHANGE}%"
echo "Weekly active installs: ${WEEKLY}" elif [ "$CHANGE" -lt 0 ] 2>/dev/null; then
if [ "$CHANGE" -gt 0 ] 2>/dev/null; then echo " Change: ${CHANGE}%"
echo " Change: +${CHANGE}%"
elif [ "$CHANGE" -lt 0 ] 2>/dev/null; then
echo " Change: ${CHANGE}%"
fi
echo ""
fi fi
echo ""
# ─── Skill popularity (top 10) ─────────────────────────────── # ─── Skill popularity (top 10) ───────────────────────────────
echo "Top skills (last 7 days)" echo "Top skills (last 7 days)"
echo "────────────────────────" echo "────────────────────────"
# Query telemetry_events, group by skill # Parse top_skills array from JSON
EVENTS="$(query "telemetry_events" "select=skill,gstack_version&event_type=eq.skill_run&event_timestamp=gte.${WEEK_AGO}&limit=1000" 2>/dev/null || echo "[]")" SKILLS="$(echo "$DATA" | grep -o '"top_skills":\[[^]]*\]' || echo "")"
if [ -n "$SKILLS" ] && [ "$SKILLS" != '"top_skills":[]' ]; then
if [ "$EVENTS" != "[]" ] && [ -n "$EVENTS" ]; then echo "$SKILLS" | grep -o '"skill":"[^"]*","count":[0-9]*' | while read -r ENTRY; do
echo "$EVENTS" | grep -o '"skill":"[^"]*"' | awk -F'"' '{print $4}' | sort | uniq -c | sort -rn | head -10 | while read -r COUNT SKILL; do SKILL="$(echo "$ENTRY" | grep -o '"skill":"[^"]*"' | awk -F'"' '{print $4}')"
printf " /%-20s %d runs\n" "$SKILL" "$COUNT" COUNT="$(echo "$ENTRY" | grep -o '"count":[0-9]*' | grep -o '[0-9]*')"
printf " /%-20s %s runs\n" "$SKILL" "$COUNT"
done done
else else
echo " No data yet" echo " No data yet"
@ -85,11 +73,11 @@ echo ""
echo "Top crash clusters" echo "Top crash clusters"
echo "──────────────────" echo "──────────────────"
CRASHES="$(query "crash_clusters" "select=error_class,gstack_version,total_occurrences,identified_users&limit=5" 2>/dev/null || echo "[]")" CRASHES="$(echo "$DATA" | grep -o '"crashes":\[[^]]*\]' || echo "")"
if [ -n "$CRASHES" ] && [ "$CRASHES" != '"crashes":[]' ]; then
if [ "$CRASHES" != "[]" ] && [ -n "$CRASHES" ]; then echo "$CRASHES" | grep -o '"error_class":"[^"]*"[^}]*"total_occurrences":[0-9]*' | head -5 | while read -r ENTRY; do
echo "$CRASHES" | grep -o '"error_class":"[^"]*"' | awk -F'"' '{print $4}' | head -5 | while read -r ERR; do ERR="$(echo "$ENTRY" | grep -o '"error_class":"[^"]*"' | awk -F'"' '{print $4}')"
C="$(echo "$CRASHES" | grep -o "\"error_class\":\"$ERR\"[^}]*\"total_occurrences\":[0-9]*" | grep -o '"total_occurrences":[0-9]*' | head -1 | grep -o '[0-9]*')" C="$(echo "$ENTRY" | grep -o '"total_occurrences":[0-9]*' | grep -o '[0-9]*')"
printf " %-30s %s occurrences\n" "$ERR" "${C:-?}" printf " %-30s %s occurrences\n" "$ERR" "${C:-?}"
done done
else else
@ -101,9 +89,12 @@ echo ""
echo "Version distribution (last 7 days)" echo "Version distribution (last 7 days)"
echo "───────────────────────────────────" echo "───────────────────────────────────"
if [ "$EVENTS" != "[]" ] && [ -n "$EVENTS" ]; then VERSIONS="$(echo "$DATA" | grep -o '"versions":\[[^]]*\]' || echo "")"
echo "$EVENTS" | grep -o '"gstack_version":"[^"]*"' | awk -F'"' '{print $4}' | sort | uniq -c | sort -rn | head -5 | while read -r COUNT VER; do if [ -n "$VERSIONS" ] && [ "$VERSIONS" != '"versions":[]' ]; then
printf " v%-15s %d events\n" "$VER" "$COUNT" 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 done
else else
echo " No data yet" echo " No data yet"

View File

@ -3,11 +3,12 @@
# #
# Fire-and-forget, backgrounded, rate-limited to once per 5 minutes. # Fire-and-forget, backgrounded, rate-limited to once per 5 minutes.
# Strips local-only fields before sending. Respects privacy tiers. # Strips local-only fields before sending. Respects privacy tiers.
# Posts to the telemetry-ingest edge function (not PostgREST directly).
# #
# Env overrides (for testing): # Env overrides (for testing):
# GSTACK_STATE_DIR — override ~/.gstack state directory # GSTACK_STATE_DIR — override ~/.gstack state directory
# GSTACK_DIR — override auto-detected gstack root # GSTACK_DIR — override auto-detected gstack root
# GSTACK_TELEMETRY_ENDPOINT — override Supabase endpoint URL # GSTACK_SUPABASE_URL — override Supabase project URL
set -uo pipefail set -uo pipefail
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" 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" CONFIG_CMD="$GSTACK_DIR/bin/gstack-config"
# Source Supabase config if not overridden by env # 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" . "$GSTACK_DIR/supabase/config.sh"
fi fi
ENDPOINT="${GSTACK_TELEMETRY_ENDPOINT:-}" SUPABASE_URL="${GSTACK_SUPABASE_URL:-}"
ANON_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" ANON_KEY="${GSTACK_SUPABASE_ANON_KEY:-}"
# ─── Pre-checks ────────────────────────────────────────────── # ─── Pre-checks ──────────────────────────────────────────────
# No endpoint configured yet → exit silently # No Supabase URL configured yet → exit silently
[ -z "$ENDPOINT" ] && exit 0 [ -z "$SUPABASE_URL" ] && exit 0
# No JSONL file → nothing to sync # No JSONL file → nothing to sync
[ -f "$JSONL_FILE" ] || exit 0 [ -f "$JSONL_FILE" ] || exit 0
@ -66,6 +67,8 @@ UNSENT="$(tail -n "+$SKIP" "$JSONL_FILE" 2>/dev/null || true)"
[ -z "$UNSENT" ] && exit 0 [ -z "$UNSENT" ] && exit 0
# ─── Strip local-only fields and build batch ───────────────── # ─── 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="[" BATCH="["
FIRST=true FIRST=true
COUNT=0 COUNT=0
@ -75,13 +78,10 @@ while IFS= read -r LINE; do
[ -z "$LINE" ] && continue [ -z "$LINE" ] && continue
echo "$LINE" | grep -q '^{' || 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 \ CLEAN="$(echo "$LINE" | sed \
-e 's/,"_repo_slug":"[^"]*"//g' \ -e 's/,"_repo_slug":"[^"]*"//g' \
-e 's/,"_branch":"[^"]*"//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')" -e 's/,"repo":"[^"]*"//g')"
# If anonymous tier, strip installation_id # If anonymous tier, strip installation_id
@ -106,21 +106,31 @@ BATCH="$BATCH]"
# Nothing to send after filtering # Nothing to send after filtering
[ "$COUNT" -eq 0 ] && exit 0 [ "$COUNT" -eq 0 ] && exit 0
# ─── POST to Supabase ──────────────────────────────────────── # ─── POST to edge function ───────────────────────────────────
HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \ RESP_FILE="$(mktemp /tmp/gstack-sync-XXXXXX 2>/dev/null || echo "/tmp/gstack-sync-$$")"
-X POST "${ENDPOINT}/telemetry_events" \ HTTP_CODE="$(curl -s -w '%{http_code}' --max-time 10 \
-X POST "${SUPABASE_URL}/functions/v1/telemetry-ingest" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "apikey: ${ANON_KEY}" \ -H "apikey: ${ANON_KEY}" \
-H "Authorization: Bearer ${ANON_KEY}" \ -o "$RESP_FILE" \
-H "Prefer: return=minimal" \
-d "$BATCH" 2>/dev/null || echo "000")" -d "$BATCH" 2>/dev/null || echo "000")"
# ─── Update cursor on success (2xx) ───────────────────────── # ─── Update cursor on success (2xx) ─────────────────────────
case "$HTTP_CODE" in case "$HTTP_CODE" in
2*) NEW_CURSOR=$(( CURSOR + COUNT )) 2*)
echo "$NEW_CURSOR" > "$CURSOR_FILE" 2>/dev/null || true ;; # 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 esac
rm -f "$RESP_FILE" 2>/dev/null || true
# Update rate limit marker # Update rate limit marker
touch "$RATE_FILE" 2>/dev/null || true touch "$RATE_FILE" 2>/dev/null || true

View File

@ -160,25 +160,22 @@ fi
mkdir -p "$STATE_DIR" mkdir -p "$STATE_DIR"
# Fire Supabase install ping in background (parallel, non-blocking) # Fire Supabase install ping in background (parallel, non-blocking)
# This logs an update check event for community health metrics. # This logs an update check event for community health metrics via edge function.
# If the endpoint isn't configured or Supabase is down, this is a no-op. # If Supabase is not configured or telemetry is off, this is a no-op.
# Source Supabase config for install ping if [ -z "${GSTACK_SUPABASE_URL:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then
if [ -z "${GSTACK_TELEMETRY_ENDPOINT:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then
. "$GSTACK_DIR/supabase/config.sh" . "$GSTACK_DIR/supabase/config.sh"
fi fi
_SUPA_ENDPOINT="${GSTACK_TELEMETRY_ENDPOINT:-}" _SUPA_URL="${GSTACK_SUPABASE_URL:-}"
_SUPA_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" _SUPA_KEY="${GSTACK_SUPABASE_ANON_KEY:-}"
# Respect telemetry opt-out — don't ping Supabase if user set telemetry: off # 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)" _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:]')" _OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
curl -sf --max-time 5 \ 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 "Content-Type: application/json" \
-H "apikey: ${_SUPA_KEY}" \ -H "apikey: ${_SUPA_KEY}" \
-H "Authorization: Bearer ${_SUPA_KEY}" \ -d "{\"version\":\"$LOCAL\",\"os\":\"$_OS\"}" \
-H "Prefer: return=minimal" \
-d "{\"gstack_version\":\"$LOCAL\",\"os\":\"$_OS\"}" \
>/dev/null 2>&1 & >/dev/null 2>&1 &
fi fi

View File

@ -1,10 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Supabase project config for gstack telemetry # Supabase project config for gstack telemetry
# These are PUBLIC keys — safe to commit (like Firebase public config). # 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_URL="https://frugpmstpnojnhfyimgv.supabase.co"
GSTACK_SUPABASE_ANON_KEY="sb_publishable_tR4i6cyMIrYTE3s6OyHGHw_ppx2p6WK" GSTACK_SUPABASE_ANON_KEY="sb_publishable_tR4i6cyMIrYTE3s6OyHGHw_ppx2p6WK"
# Telemetry ingest endpoint (Data API)
GSTACK_TELEMETRY_ENDPOINT="${GSTACK_SUPABASE_URL}/rest/v1"