chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
@@ -0,0 +1,333 @@
|
||||
# jcode Telemetry Worker
|
||||
|
||||
Cloudflare Worker that receives anonymous telemetry events from jcode.
|
||||
|
||||
The headline number is **Total users**: distinct, non-CI `telemetry_id`s that
|
||||
ever installed jcode OR did meaningful work in it. Run it with:
|
||||
|
||||
```bash
|
||||
wrangler d1 execute jcode-telemetry --remote --file=users.sql
|
||||
```
|
||||
|
||||
## Storage architecture
|
||||
|
||||
Events are dual-written to two stores with different jobs:
|
||||
|
||||
1. **Workers Analytics Engine firehose** (`jcode_telemetry_firehose` dataset):
|
||||
every event, written first. Time-series store with no database size cap and
|
||||
~90-day retention (adaptive sampling on reads; `index1` is the
|
||||
`telemetry_id`, so per-user sampling stays accurate). This is the primary
|
||||
store for high-volume raw analysis (`turn_end`, `session_start`,
|
||||
`onboarding_step` volume) and the safety net: telemetry keeps recording
|
||||
even when D1 is full. Column mapping lives in `FIREHOSE_SCHEMA` in
|
||||
`src/worker.js` and is **append-only** (never reorder or repurpose a
|
||||
position). Query it via the [Analytics Engine SQL API](https://developers.cloudflare.com/analytics/analytics-engine/sql-api/):
|
||||
```bash
|
||||
# Requires an API token with Account Analytics read. Example: auth failure
|
||||
# reasons over the last 7 days (blob9=auth_provider, blob11=auth_failure_reason).
|
||||
curl -s "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/analytics_engine/sql" \
|
||||
-H "Authorization: Bearer $CF_ANALYTICS_TOKEN" \
|
||||
-d "SELECT blob9 AS provider, blob11 AS reason, SUM(_sample_interval) AS n
|
||||
FROM jcode_telemetry_firehose
|
||||
WHERE blob1 = 'onboarding_step' AND blob8 = 'auth_failed'
|
||||
AND timestamp > NOW() - INTERVAL '7' DAY
|
||||
GROUP BY provider, reason ORDER BY n DESC"
|
||||
```
|
||||
2. **D1** (`jcode-telemetry` database): the durable relational store for
|
||||
identity anchors (`install`, `feedback`), auth/lifecycle events, the
|
||||
`daily_active_users` rollup, and a retention-pruned raw tail of the
|
||||
high-volume events (see `RETENTION_DAYS`). All the dashboard SQL in this
|
||||
repo (`users.sql`, `dau.sql`, `health.sql`) reads D1.
|
||||
|
||||
### D1 size self-defense
|
||||
|
||||
D1 hard-caps databases at 500 MB on the free plan; at the cap every insert
|
||||
500s and telemetry silently stops (June 2026: ~3 days lost). Defenses, in
|
||||
order:
|
||||
|
||||
- The worker observes `meta.size_after` on every D1 write. Past the soft
|
||||
limit (`D1_SOFT_LIMIT_BYTES`, just above the file's high-water mark) it
|
||||
triggers an **emergency prune** (halved retention windows, rate-limited to
|
||||
one per 10 minutes per isolate) instead of waiting for the nightly cron.
|
||||
- If an insert fails with a SQLITE_FULL-class error, the emergency prune runs
|
||||
immediately, bounding a June-style outage to minutes instead of days.
|
||||
- The nightly cron re-checks size after the normal prune and escalates to the
|
||||
emergency prune if still over the soft limit.
|
||||
- If a D1 insert still fails, the request returns `{ok, durable:false,
|
||||
firehose:true}` instead of a 500, because the event was captured in the
|
||||
firehose.
|
||||
- `GET /v1/health` reports `db_size_bytes` vs the soft limit for external
|
||||
monitoring.
|
||||
|
||||
Note: D1 has no `VACUUM`, so the file never shrinks; deletes only free pages
|
||||
internally for reuse. If bloat itself becomes the problem, rotate to a fresh
|
||||
database (create new D1 DB, copy live rows, repoint `wrangler.toml`).
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install wrangler: `npm install`
|
||||
|
||||
2. Create D1 database:
|
||||
```bash
|
||||
wrangler d1 create jcode-telemetry
|
||||
```
|
||||
|
||||
3. Update `wrangler.toml` with the database ID from step 2
|
||||
|
||||
4. Initialize schema:
|
||||
```bash
|
||||
wrangler d1 execute jcode-telemetry --file=schema.sql
|
||||
```
|
||||
|
||||
### Migrating an existing database
|
||||
|
||||
If your production database was created before the latest telemetry fields were added,
|
||||
apply all remote migrations:
|
||||
|
||||
```bash
|
||||
wrangler d1 execute jcode-telemetry --remote --file=migrations/0001_expand_events.sql
|
||||
wrangler d1 execute jcode-telemetry --remote --file=migrations/0002_transport_metrics.sql
|
||||
wrangler d1 execute jcode-telemetry --remote --file=migrations/0003_usage_expansion.sql
|
||||
wrangler d1 execute jcode-telemetry --remote --file=migrations/0004_telemetry_phase123.sql
|
||||
wrangler d1 execute jcode-telemetry --remote --file=migrations/0005_workflow_turn_telemetry.sql
|
||||
```
|
||||
|
||||
(...and so on through the latest numbered migration; each also has an
|
||||
`npm run migrate:<name>` alias, see Ops helpers below. The newest is
|
||||
`migrations/0018_web_quality_telemetry.sql` / `npm run migrate:web-quality`.)
|
||||
|
||||
Then redeploy the worker:
|
||||
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
5. Deploy:
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
6. Set up custom domain (optional): point `telemetry.jcode.dev` to the worker in Cloudflare dashboard
|
||||
|
||||
### Ops helpers
|
||||
|
||||
```bash
|
||||
# Apply schema catch-up migrations
|
||||
npm run migrate:expand
|
||||
npm run migrate:transport
|
||||
npm run migrate:usage
|
||||
npm run migrate:phase123
|
||||
npm run migrate:workflow
|
||||
npm run migrate:tokens
|
||||
npm run migrate:dashboard-indexes
|
||||
npm run migrate:feedback-text
|
||||
npm run migrate:daily-active
|
||||
npm run migrate:daily-active-backfill
|
||||
npm run migrate:daily-active-ci
|
||||
npm run migrate:detail-fields
|
||||
npm run migrate:dau-full-backfill
|
||||
npm run migrate:auth-failure-reason
|
||||
npm run migrate:web-subscription
|
||||
npm run migrate:discovery
|
||||
npm run migrate:web-quality
|
||||
|
||||
# Run the health dashboard query
|
||||
npm run health
|
||||
```
|
||||
|
||||
## Event types
|
||||
|
||||
CLI events (sent by jcode itself): `install`, `upgrade`, `auth_success`,
|
||||
`onboarding_step`, `feedback`, `session_start`, `turn_end`, `session_end`,
|
||||
`session_crash`.
|
||||
|
||||
### Website analytics and quality events (migrations 0016 and 0018)
|
||||
|
||||
Sent by the beacon on `https://jcode.sh` (and the
|
||||
`https://solosystems.pages.dev` preview). The browser mints an anonymous
|
||||
`visitor_id` UUID in localStorage; the worker uses it as the telemetry id and
|
||||
fills in `version`/`os`/`arch` defaults, so the beacon payload can stay tiny.
|
||||
Web-only fields are stored in the `web_details` table (keyed by `event_id`,
|
||||
like `session_details`/`turn_details`) because `events` is near D1's
|
||||
100-column cap.
|
||||
|
||||
- `web_pageview`: `path`, `referrer`, `visitor_id`, `utm_source`,
|
||||
`utm_medium`, `utm_campaign`
|
||||
- `web_cta_click`: `path`, `cta` (e.g. `plus_early_access`,
|
||||
`flagship_early_access`, `install`), `visitor_id`
|
||||
- `web_vital`: `path`, `visitor_id`, standard `metric_name` (`CLS`, `FCP`,
|
||||
`INP`, `LCP`, or `TTFB`), finite nonnegative `metric_value`, and `rating`
|
||||
(`good`, `needs-improvement`, or `poor`). Values are capped at 10 for CLS
|
||||
and 300000 ms for the other metrics. D1 retention is 30 days.
|
||||
- `web_error`: `path`, `visitor_id`, and coarse `error_kind` (`script`,
|
||||
`promise`, or `resource`). Error messages, stacks, filenames, and URLs are
|
||||
never stored. D1 retention is 90 days.
|
||||
|
||||
### Token subscription plan events (migration 0016)
|
||||
|
||||
All require `account_id`; `tier` and `model` are attached where relevant
|
||||
(`model` is stored in the existing generic `model_start` column).
|
||||
|
||||
- `subscription_login`: `account_id`, `tier`
|
||||
- `subscription_activated`: `account_id`, `tier`
|
||||
- `subscription_budget_exhausted`: `account_id`, `tier`, `model`
|
||||
- `subscription_router_error`: `account_id`, `tier`, `model`
|
||||
- `account_linked`: `telemetry_id` (the standard `id` field) + `account_id`.
|
||||
This is the analytics<->account join anchor: it ties an anonymous CLI
|
||||
`telemetry_id` to a subscription `account_id`, and is never pruned.
|
||||
|
||||
Web + subscription events are firehosed to the separate `jcode_web_firehose`
|
||||
dataset (`FIREHOSE_WEB_SCHEMA` in `src/worker.js`, also append-only): the
|
||||
main `FIREHOSE_SCHEMA` is at Analytics Engine's 20-blob/20-double capacity.
|
||||
For web events `index1` is the `visitor_id`.
|
||||
The 0018 fields were appended without reordering: `blob18=metric_name`,
|
||||
`blob19=rating`, `blob20=error_kind`, and `double2=metric_value`.
|
||||
|
||||
## Querying Data
|
||||
|
||||
```bash
|
||||
# Total installs (raw, and excluding CI runners which mint a fresh id per job)
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT COUNT(DISTINCT telemetry_id) AS raw_installs, COUNT(DISTINCT CASE WHEN is_ci = 0 THEN telemetry_id END) AS installs_noci FROM events WHERE event = 'install'"
|
||||
|
||||
# Web vitals by route and rating over the retained 30-day D1 window
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT w.path, w.metric_name, w.rating, COUNT(*) AS samples, AVG(w.metric_value) AS avg_value FROM events e JOIN web_details w USING (event_id) WHERE e.event = 'web_vital' AND e.created_at > datetime('now', '-30 days') GROUP BY 1, 2, 3 ORDER BY 1, 2, 3"
|
||||
|
||||
# Classified web errors by route over the retained 90-day D1 window
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT w.path, w.error_kind, COUNT(*) AS errors FROM events e JOIN web_details w USING (event_id) WHERE e.event = 'web_error' AND e.created_at > datetime('now', '-90 days') GROUP BY 1, 2 ORDER BY errors DESC"
|
||||
|
||||
# Analytics Engine web-vital sample counts (append-only positions from 0018)
|
||||
curl -s "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/analytics_engine/sql" \
|
||||
-H "Authorization: Bearer $CF_ANALYTICS_TOKEN" \
|
||||
-d "SELECT blob18 AS metric_name, blob19 AS rating, SUM(_sample_interval) AS samples, AVG(double2) AS avg_value FROM jcode_web_firehose WHERE blob1 = 'web_vital' AND timestamp > NOW() - INTERVAL '7' DAY GROUP BY metric_name, rating ORDER BY metric_name, rating"
|
||||
|
||||
# Weekly / monthly active users (canonical: use the rollup so every window
|
||||
# shares one "meaningful" definition and includes session_crash + turn_end days).
|
||||
# meaningful_release_*_noci is the headline product metric: real users on the
|
||||
# release channel, excluding automated CI traffic (ephemeral runners that mint a
|
||||
# fresh telemetry_id per job and otherwise inflate users/installs and tank retention).
|
||||
# WAU (last 7 UTC days):
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT COUNT(DISTINCT telemetry_id) AS raw_wau, COUNT(DISTINCT CASE WHEN meaningful_active > 0 THEN telemetry_id END) AS meaningful_wau, COUNT(DISTINCT CASE WHEN meaningful_release_active > 0 THEN telemetry_id END) AS meaningful_release_wau, COUNT(DISTINCT CASE WHEN meaningful_release_active > 0 AND last_is_ci = 0 THEN telemetry_id END) AS meaningful_release_wau_noci FROM daily_active_users WHERE activity_date > date('now', '-7 days')"
|
||||
|
||||
# MAU (last 30 UTC days):
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT COUNT(DISTINCT telemetry_id) AS raw_mau, COUNT(DISTINCT CASE WHEN meaningful_active > 0 THEN telemetry_id END) AS meaningful_mau, COUNT(DISTINCT CASE WHEN meaningful_release_active > 0 THEN telemetry_id END) AS meaningful_release_mau, COUNT(DISTINCT CASE WHEN meaningful_release_active > 0 AND last_is_ci = 0 THEN telemetry_id END) AS meaningful_release_mau_noci FROM daily_active_users WHERE activity_date > date('now', '-30 days')"
|
||||
|
||||
# Raw vs meaningful active users this week, directly from raw events (matches the
|
||||
# rollup definition: counts session_end/session_crash AND turn_end activity).
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT COUNT(DISTINCT telemetry_id) AS raw_wau, COUNT(DISTINCT CASE WHEN (event IN ('session_end','session_crash') AND (turns > 0 OR had_user_prompt > 0 OR had_assistant_response > 0 OR assistant_responses > 0 OR tool_calls > 0 OR executed_tool_calls > 0 OR duration_secs > 0 OR error_provider_timeout > 0 OR error_auth_failed > 0 OR error_tool_error > 0 OR error_mcp_error > 0 OR error_rate_limited > 0 OR provider_switches > 0 OR model_switches > 0)) OR (event = 'turn_end' AND (assistant_responses > 0 OR tool_calls > 0 OR executed_tool_calls > 0 OR file_write_calls > 0 OR tests_run > 0 OR turn_success > 0)) THEN telemetry_id END) AS meaningful_wau FROM events WHERE event IN ('session_end','session_crash','turn_end') AND created_at > datetime('now', '-7 days')"
|
||||
|
||||
# Provider distribution for meaningful sessions
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT provider_end, COUNT(*) as sessions FROM events WHERE event = 'session_end' AND (turns > 0 OR duration_mins > 0 OR error_provider_timeout > 0 OR error_auth_failed > 0 OR error_tool_error > 0 OR error_mcp_error > 0 OR error_rate_limited > 0 OR provider_switches > 0 OR model_switches > 0) GROUP BY provider_end ORDER BY sessions DESC"
|
||||
|
||||
# Average meaningful session duration
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT AVG(duration_mins) as avg_mins, AVG(turns) as avg_turns FROM events WHERE event = 'session_end' AND (turns > 0 OR duration_mins > 0 OR error_provider_timeout > 0 OR error_auth_failed > 0 OR error_tool_error > 0 OR error_mcp_error > 0 OR error_rate_limited > 0 OR provider_switches > 0 OR model_switches > 0)"
|
||||
|
||||
# Error rates. Count affected sessions/users, not raw sums: raw sums are
|
||||
# dominated by runaway retry loops (one pre-breaker session logged 18k+ auth
|
||||
# failures), which makes one broken install look like a fleet-wide outage.
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT COUNT(CASE WHEN error_provider_timeout > 0 THEN 1 END) as timeout_sessions, COUNT(CASE WHEN error_rate_limited > 0 THEN 1 END) as rate_limited_sessions, COUNT(CASE WHEN error_auth_failed > 0 THEN 1 END) as auth_failed_sessions, COUNT(DISTINCT CASE WHEN error_auth_failed > 0 THEN telemetry_id END) as auth_failed_users FROM events WHERE event = 'session_end'"
|
||||
|
||||
# Auth failure reasons (requires 0015; reasons recorded from explicit auth_failed onboarding steps)
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT auth_provider, auth_failure_reason, COUNT(*) AS n, COUNT(DISTINCT telemetry_id) AS users FROM events WHERE event = 'onboarding_step' AND step = 'auth_failed' AND created_at > datetime('now', '-30 days') GROUP BY 1, 2 ORDER BY n DESC"
|
||||
|
||||
# Version adoption
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT version, COUNT(DISTINCT telemetry_id) as users FROM events GROUP BY version ORDER BY version DESC"
|
||||
|
||||
# Heavy telemetry IDs (useful for spotting dev/test noise)
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT telemetry_id, COUNT(*) AS session_ends FROM events WHERE event = 'session_end' GROUP BY telemetry_id ORDER BY session_ends DESC LIMIT 20"
|
||||
|
||||
# OS/arch breakdown
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT os, arch, COUNT(DISTINCT telemetry_id) as users FROM events GROUP BY os, arch ORDER BY users DESC"
|
||||
|
||||
# Transport breakdown (requires 0002 transport migration)
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT SUM(transport_https) AS https, SUM(transport_persistent_ws_fresh) AS ws_fresh, SUM(transport_persistent_ws_reuse) AS ws_reuse, SUM(transport_cli_subprocess) AS cli, SUM(transport_native_http2) AS native_http2, SUM(transport_other) AS other FROM events WHERE event IN ('session_end', 'session_crash')"
|
||||
|
||||
# Telemetry health dashboard
|
||||
wrangler d1 execute jcode-telemetry --file=health.sql
|
||||
|
||||
# Daily active users. Prefer meaningful_release_* as the headline product metric.
|
||||
npm run dau
|
||||
|
||||
# Fast UTC-day DAU from the ingest-time rollup table
|
||||
wrangler d1 execute jcode-telemetry --remote --command "SELECT COUNT(*) AS raw_today, SUM(CASE WHEN meaningful_active > 0 THEN 1 ELSE 0 END) AS meaningful_today, SUM(CASE WHEN release_active > 0 THEN 1 ELSE 0 END) AS raw_release_today, SUM(CASE WHEN meaningful_release_active > 0 THEN 1 ELSE 0 END) AS meaningful_release_today FROM daily_active_users WHERE activity_date = date('now')"
|
||||
|
||||
# Auth activation funnel by provider
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT auth_provider, COUNT(DISTINCT telemetry_id) AS users FROM events WHERE event = 'auth_success' GROUP BY auth_provider ORDER BY users DESC"
|
||||
|
||||
# Onboarding funnel steps
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT step, COUNT(DISTINCT telemetry_id) AS users FROM events WHERE event = 'onboarding_step' GROUP BY step ORDER BY users DESC"
|
||||
|
||||
# Recent explicit feedback
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT created_at, feedback_text, feedback_rating, feedback_reason, version, build_channel FROM events WHERE event = 'feedback' ORDER BY created_at DESC LIMIT 50"
|
||||
|
||||
# Session starts by UTC hour (workflow timing)
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT session_start_hour_utc, COUNT(*) AS sessions FROM events WHERE event = 'session_start' GROUP BY session_start_hour_utc ORDER BY session_start_hour_utc"
|
||||
|
||||
# Multi-sessioning rate
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT AVG(CASE WHEN multi_sessioned > 0 THEN 1.0 ELSE 0.0 END) AS multi_session_rate FROM events WHERE event IN ('session_end', 'session_crash') AND created_at > datetime('now', '-30 days')"
|
||||
|
||||
# Per-turn latency and success
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT AVG(turn_active_duration_ms) AS avg_turn_ms, AVG(CASE WHEN turn_success > 0 THEN 1.0 ELSE 0.0 END) AS turn_success_rate FROM events WHERE event = 'turn_end' AND created_at > datetime('now', '-30 days')"
|
||||
|
||||
# Build-channel cleanup for active users
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT build_channel, COUNT(DISTINCT telemetry_id) AS users FROM events WHERE event IN ('session_end', 'session_crash') AND created_at > datetime('now', '-30 days') GROUP BY build_channel ORDER BY users DESC"
|
||||
|
||||
# D7 retention for users who installed 8-14 days ago
|
||||
wrangler d1 execute jcode-telemetry --command "WITH cohort AS (SELECT DISTINCT telemetry_id FROM events WHERE event = 'install' AND created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days')), retained AS (SELECT DISTINCT telemetry_id FROM events WHERE event IN ('session_end', 'session_crash') AND created_at >= datetime('now', '-7 days')) SELECT COUNT(*) AS cohort_users, (SELECT COUNT(*) FROM cohort WHERE telemetry_id IN retained) AS retained_users FROM cohort"
|
||||
|
||||
# Feature adoption (last 30d)
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT SUM(feature_memory_used) AS memory_sessions, SUM(feature_swarm_used) AS swarm_sessions, SUM(feature_web_used) AS web_sessions, SUM(feature_email_used) AS email_sessions, SUM(feature_mcp_used) AS mcp_sessions, SUM(feature_side_panel_used) AS side_panel_sessions, SUM(feature_goal_used) AS goal_sessions, SUM(feature_selfdev_used) AS selfdev_sessions, SUM(feature_background_used) AS background_sessions, SUM(feature_subagent_used) AS subagent_sessions FROM events WHERE event IN ('session_end', 'session_crash') AND created_at > datetime('now', '-30 days')"
|
||||
|
||||
# Session success rate + abandonment rate (last 30d)
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT AVG(CASE WHEN session_success > 0 THEN 1.0 ELSE 0.0 END) AS success_rate, AVG(CASE WHEN abandoned_before_response > 0 THEN 1.0 ELSE 0.0 END) AS abandoned_before_response_rate FROM events WHERE event IN ('session_end', 'session_crash') AND created_at > datetime('now', '-30 days')"
|
||||
|
||||
# Tool and response latency (last 30d)
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT AVG(first_assistant_response_ms) AS avg_first_response_ms, AVG(first_tool_success_ms) AS avg_first_tool_success_ms, AVG(CASE WHEN executed_tool_calls > 0 THEN CAST(tool_latency_total_ms AS REAL) / executed_tool_calls END) AS avg_tool_latency_ms FROM events WHERE event IN ('session_end', 'session_crash') AND created_at > datetime('now', '-30 days')"
|
||||
|
||||
# --- Website + subscription analytics (requires 0016) ---
|
||||
|
||||
# Daily web visitors (distinct anonymous visitor_ids per UTC day, last 30d)
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT date(e.created_at) AS day, COUNT(DISTINCT w.visitor_id) AS visitors, COUNT(*) AS pageviews FROM events e JOIN web_details w ON w.event_id = e.event_id WHERE e.event = 'web_pageview' AND e.created_at > datetime('now', '-30 days') GROUP BY day ORDER BY day"
|
||||
|
||||
# Pricing-page funnel: pageview -> CTA click by tier (last 30d).
|
||||
# cta encodes the tier (plus_early_access / flagship_early_access / install).
|
||||
wrangler d1 execute jcode-telemetry --command "WITH viewers AS (SELECT COUNT(DISTINCT w.visitor_id) AS n FROM events e JOIN web_details w ON w.event_id = e.event_id WHERE e.event = 'web_pageview' AND w.path = '/pricing' AND e.created_at > datetime('now', '-30 days')) SELECT w.cta, COUNT(DISTINCT w.visitor_id) AS clickers, (SELECT n FROM viewers) AS pricing_viewers, ROUND(1.0 * COUNT(DISTINCT w.visitor_id) / MAX(1, (SELECT n FROM viewers)), 4) AS click_through FROM events e JOIN web_details w ON w.event_id = e.event_id WHERE e.event = 'web_cta_click' AND w.path = '/pricing' AND e.created_at > datetime('now', '-30 days') GROUP BY w.cta ORDER BY clickers DESC"
|
||||
|
||||
# Traffic sources for pricing pageviews (last 30d)
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT w.utm_source, w.utm_medium, w.utm_campaign, COUNT(DISTINCT w.visitor_id) AS visitors FROM events e JOIN web_details w ON w.event_id = e.event_id WHERE e.event = 'web_pageview' AND e.created_at > datetime('now', '-30 days') GROUP BY 1, 2, 3 ORDER BY visitors DESC"
|
||||
|
||||
# Subscription activations by tier (last 30d)
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT tier, COUNT(DISTINCT account_id) AS accounts, COUNT(*) AS activations FROM events WHERE event = 'subscription_activated' AND created_at > datetime('now', '-30 days') GROUP BY tier ORDER BY accounts DESC"
|
||||
|
||||
# Budget exhaustion count (accounts hitting their token budget, by tier, last 30d)
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT tier, COUNT(*) AS exhaustion_events, COUNT(DISTINCT account_id) AS accounts FROM events WHERE event = 'subscription_budget_exhausted' AND created_at > datetime('now', '-30 days') GROUP BY tier ORDER BY exhaustion_events DESC"
|
||||
|
||||
# Subscription router errors by tier/model (last 7d)
|
||||
wrangler d1 execute jcode-telemetry --command "SELECT tier, model_start AS model, COUNT(*) AS errors, COUNT(DISTINCT account_id) AS accounts FROM events WHERE event = 'subscription_router_error' AND created_at > datetime('now', '-7 days') GROUP BY 1, 2 ORDER BY errors DESC"
|
||||
|
||||
# account_linked join example: CLI usage (meaningful active days, last 30d)
|
||||
# per subscribed account, via the telemetry_id <-> account_id anchor.
|
||||
wrangler d1 execute jcode-telemetry --command "WITH links AS (SELECT DISTINCT telemetry_id, account_id FROM events WHERE event = 'account_linked') SELECT l.account_id, COUNT(DISTINCT d.activity_date) AS active_days_30d, SUM(d.turn_end_count) AS turns_30d FROM links l JOIN daily_active_users d ON d.telemetry_id = l.telemetry_id WHERE d.activity_date > date('now', '-30 days') AND d.meaningful_active > 0 GROUP BY l.account_id ORDER BY active_days_30d DESC LIMIT 50"
|
||||
```
|
||||
|
||||
## What to watch for
|
||||
|
||||
- `session_start` far exceeding `session_end + session_crash` for multiple days
|
||||
- `session_crash = 0` for long periods despite known crashes
|
||||
- large `lifecycle_ids_without_install` counts
|
||||
- a single telemetry ID dominating session totals (dev/test skew)
|
||||
- zeroed transport totals after transport-aware releases (missing migration)
|
||||
- `daily_active_users` row counts diverging from raw distinct-user checks
|
||||
- headline DAU including `build_channel != 'release'` or raw event counts instead of distinct users
|
||||
- headline DAU/installs including CI traffic (`is_ci = 1`); prefer the `*_noci` columns. A spike in `ci_ids_30d` / `ci_install_ids` from `health.sql` means CI runners are inflating user and install counts.
|
||||
|
||||
## Accuracy notes
|
||||
|
||||
- DAU/WAU/MAU should be distinct `telemetry_id` counts, never event counts. Heavy users and long-running agents can emit thousands of `turn_end` events in a day.
|
||||
- Use `meaningful_release_active` for headline product usage. It excludes local/dev/git-checkout traffic and open/close sessions with no meaningful lifecycle activity.
|
||||
- For the cleanest headline numbers, prefer the `*_noci` columns, which additionally exclude `is_ci = 1` traffic. Ephemeral CI runners mint a fresh `telemetry_id` per job, so unfiltered they look like brand-new users and installs, inflating active-user/install counts and depressing retention. The client also skips the `install` event under CI, so historical CI installs (before that ships) are the main residual source; the rollup's `last_is_ci` flag lets dashboards filter the rest. Raw events stay tagged (not dropped) so CI crash/error signal is still queryable.
|
||||
- Meaningful activity is derived from `session_end`/`session_crash` **and** `turn_end` events. A `turn_end` only fires after a real user turn completes, so counting it keeps the metric accurate for users whose `session_end` is lost (process killed, machine shutdown, dropped final flush, or a session still open at UTC midnight).
|
||||
- **Retention pruning**: D1 hard-caps databases at 500 MB. When the cap is hit, every insert fails with HTTP 500 and telemetry silently stops being recorded (this happened in June 2026; ~3 days of events were lost). The worker now runs a nightly cron (`scheduled` handler, see `RETENTION_DAYS` in `src/worker.js`) that prunes high-volume raw rows: `turn_end`/`session_start`/`onboarding_step` after 30 days, `upgrade` after 60, `auth_success` after 180, `session_end`/`session_crash` after 365, `web_pageview`/`subscription_router_error` after 90, `web_cta_click`/`subscription_budget_exhausted` after 365, `subscription_login` after 180. `install`, `feedback`, `subscription_activated`, and `account_linked` rows are never pruned. Because of this, **historical user/DAU queries must read `daily_active_users`, not raw `events`** - the rollup is backfilled across full history (migration 0014) and maintained at insert time.
|
||||
- **D1 100-column cap**: production `events` has 98 columns after migration 0016 and D1 refuses `ALTER TABLE ADD COLUMN` past 100 (`too many columns`). Migration 0005's per-turn/session-cadence columns never applied to production `events`; migration 0013 moved those fields into `turn_details`/`session_details`, and migration 0016 put the web beacon fields in `web_details` for the same reason. Do not add new columns to `events`; add them to the detail tables.
|
||||
- Raw events remain the source of truth within their retention windows. The `daily_active_users` table is an ingest-time rollup for cheap dashboard queries and is the durable record beyond those windows.
|
||||
- The worker uses `INSERT OR IGNORE` keyed by `event_id`; rollups and detail rows are updated only when the canonical raw event insert succeeds, so client retries do not inflate counts.
|
||||
- Telemetry still undercounts users who opt out (`JCODE_NO_TELEMETRY`, `DO_NOT_TRACK`, `~/.jcode/no_telemetry`) or whose network blocks telemetry, and may overcount one person using multiple machines.
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Current UTC-day and trailing-24h DAU dashboard.
|
||||
-- Usage:
|
||||
-- wrangler d1 execute jcode-telemetry --remote --file=dau.sql
|
||||
--
|
||||
-- Note: production `events` never got migration 0005's per-turn columns (D1
|
||||
-- caps tables at 100 columns), so turn_end activity lives in `turn_details`
|
||||
-- keyed by event_id. The trailing-24h tiers join through it; the today tiers
|
||||
-- read the daily_active_users rollup, which is classified at insert time from
|
||||
-- the full client payload.
|
||||
|
||||
WITH today AS (
|
||||
SELECT
|
||||
COUNT(*) AS raw_today,
|
||||
SUM(CASE WHEN meaningful_active > 0 THEN 1 ELSE 0 END) AS meaningful_today,
|
||||
SUM(CASE WHEN release_active > 0 THEN 1 ELSE 0 END) AS raw_release_today,
|
||||
SUM(CASE WHEN meaningful_release_active > 0 THEN 1 ELSE 0 END) AS meaningful_release_today,
|
||||
-- Headline product metric: real users on the release channel, excluding
|
||||
-- automated CI traffic (ephemeral runners that mint a fresh id per job).
|
||||
SUM(CASE WHEN meaningful_release_active > 0 AND last_is_ci = 0 THEN 1 ELSE 0 END) AS meaningful_release_today_noci,
|
||||
SUM(CASE WHEN last_is_ci > 0 THEN 1 ELSE 0 END) AS ci_today
|
||||
FROM daily_active_users
|
||||
WHERE activity_date = date('now')
|
||||
), recent AS (
|
||||
SELECT
|
||||
e.telemetry_id,
|
||||
e.event,
|
||||
e.build_channel,
|
||||
e.is_ci,
|
||||
CASE
|
||||
WHEN e.event IN ('session_end', 'session_crash') AND (
|
||||
e.turns > 0 OR e.had_user_prompt > 0 OR e.had_assistant_response > 0
|
||||
OR e.assistant_responses > 0 OR e.tool_calls > 0 OR e.executed_tool_calls > 0
|
||||
OR e.duration_secs > 0 OR e.error_provider_timeout > 0 OR e.error_auth_failed > 0
|
||||
OR e.error_tool_error > 0 OR e.error_mcp_error > 0 OR e.error_rate_limited > 0
|
||||
OR e.provider_switches > 0 OR e.model_switches > 0
|
||||
) THEN 1
|
||||
WHEN e.event = 'turn_end' AND (
|
||||
td.assistant_responses > 0 OR td.tool_calls > 0 OR td.executed_tool_calls > 0
|
||||
OR td.file_write_calls > 0 OR td.tests_run > 0
|
||||
) THEN 1
|
||||
ELSE 0
|
||||
END AS meaningful
|
||||
FROM events e
|
||||
LEFT JOIN turn_details td ON td.event_id = e.event_id
|
||||
WHERE e.event IN ('session_start', 'turn_end', 'session_end', 'session_crash')
|
||||
AND e.created_at > datetime('now', '-1 day')
|
||||
), trailing_24h AS (
|
||||
SELECT
|
||||
COUNT(DISTINCT telemetry_id) AS raw_24h,
|
||||
COUNT(DISTINCT CASE WHEN meaningful = 1 THEN telemetry_id END) AS meaningful_24h,
|
||||
COUNT(DISTINCT CASE WHEN build_channel = 'release' THEN telemetry_id END) AS raw_release_24h,
|
||||
COUNT(DISTINCT CASE WHEN build_channel = 'release' AND meaningful = 1 THEN telemetry_id END) AS meaningful_release_24h,
|
||||
-- Same headline metric over a rolling 24h window, excluding CI traffic.
|
||||
COUNT(DISTINCT CASE WHEN build_channel = 'release' AND is_ci = 0 AND meaningful = 1 THEN telemetry_id END) AS meaningful_release_24h_noci,
|
||||
COUNT(DISTINCT CASE WHEN is_ci = 1 THEN telemetry_id END) AS ci_24h
|
||||
FROM recent
|
||||
)
|
||||
SELECT * FROM today, trailing_24h;
|
||||
@@ -0,0 +1,140 @@
|
||||
-- Telemetry health dashboard query.
|
||||
-- Usage:
|
||||
-- wrangler d1 execute jcode-telemetry --remote --file=health.sql
|
||||
|
||||
WITH install_ids AS (
|
||||
SELECT DISTINCT telemetry_id
|
||||
FROM events INDEXED BY idx_events_event_telemetry_created
|
||||
WHERE event = 'install'
|
||||
), lifecycle AS (
|
||||
SELECT telemetry_id, created_at
|
||||
FROM events INDEXED BY idx_events_event_telemetry_created
|
||||
WHERE event IN ('session_end', 'session_crash')
|
||||
), session_starts_by_id AS (
|
||||
SELECT DISTINCT telemetry_id
|
||||
FROM events INDEXED BY idx_events_event_telemetry_created
|
||||
WHERE event = 'session_start'
|
||||
), event_counts AS (
|
||||
SELECT
|
||||
SUM(CASE WHEN event = 'install' THEN 1 ELSE 0 END) AS install_events,
|
||||
SUM(CASE WHEN event = 'session_start' THEN 1 ELSE 0 END) AS session_starts,
|
||||
SUM(CASE WHEN event = 'session_end' THEN 1 ELSE 0 END) AS session_ends,
|
||||
SUM(CASE WHEN event = 'session_crash' THEN 1 ELSE 0 END) AS session_crashes
|
||||
FROM events INDEXED BY idx_events_event_created_telemetry
|
||||
WHERE event IN ('install', 'session_start', 'session_end', 'session_crash')
|
||||
), identity_counts AS (
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM install_ids) AS install_ids,
|
||||
(SELECT COUNT(DISTINCT telemetry_id) FROM lifecycle) AS lifecycle_ids,
|
||||
(SELECT COUNT(*) FROM session_starts_by_id) AS session_start_ids,
|
||||
(SELECT COUNT(DISTINCT lifecycle.telemetry_id)
|
||||
FROM lifecycle
|
||||
LEFT JOIN install_ids USING (telemetry_id)
|
||||
WHERE install_ids.telemetry_id IS NULL) AS lifecycle_ids_without_install
|
||||
),
|
||||
meaningful AS (
|
||||
SELECT
|
||||
SUM(CASE WHEN e.event IN ('session_end', 'session_crash') THEN 1 ELSE 0 END) AS meaningful_sessions,
|
||||
COUNT(DISTINCT e.telemetry_id) AS meaningful_users_30d
|
||||
FROM events e
|
||||
LEFT JOIN turn_details td ON td.event_id = e.event_id
|
||||
WHERE e.event IN ('session_end', 'session_crash', 'turn_end')
|
||||
AND e.created_at > datetime('now', '-30 days')
|
||||
AND (
|
||||
(e.event IN ('session_end', 'session_crash') AND (
|
||||
e.turns > 0
|
||||
OR e.duration_mins > 0
|
||||
OR e.error_provider_timeout > 0
|
||||
OR e.error_auth_failed > 0
|
||||
OR e.error_tool_error > 0
|
||||
OR e.error_mcp_error > 0
|
||||
OR e.error_rate_limited > 0
|
||||
OR e.provider_switches > 0
|
||||
OR e.model_switches > 0
|
||||
OR e.had_user_prompt > 0
|
||||
OR e.had_assistant_response > 0
|
||||
OR e.assistant_responses > 0
|
||||
OR e.tool_calls > 0
|
||||
OR e.tool_failures > 0
|
||||
OR e.executed_tool_calls > 0
|
||||
OR e.feature_memory_used > 0
|
||||
OR e.feature_swarm_used > 0
|
||||
OR e.feature_web_used > 0
|
||||
OR e.feature_email_used > 0
|
||||
OR e.feature_mcp_used > 0
|
||||
OR e.feature_side_panel_used > 0
|
||||
OR e.feature_goal_used > 0
|
||||
OR e.feature_selfdev_used > 0
|
||||
OR e.feature_background_used > 0
|
||||
OR e.feature_subagent_used > 0
|
||||
))
|
||||
-- turn_end activity lives in turn_details: production events never got
|
||||
-- migration 0005's per-turn columns (D1 caps tables at 100 columns).
|
||||
OR (e.event = 'turn_end' AND (
|
||||
td.assistant_responses > 0
|
||||
OR td.tool_calls > 0
|
||||
OR td.executed_tool_calls > 0
|
||||
OR td.file_write_calls > 0
|
||||
OR td.tests_run > 0
|
||||
))
|
||||
)
|
||||
),
|
||||
outliers AS (
|
||||
SELECT
|
||||
MAX(session_events) AS max_session_events_one_id,
|
||||
SUM(CASE WHEN rn <= 5 THEN session_events ELSE 0 END) AS top5_session_events,
|
||||
SUM(session_events) AS total_session_events
|
||||
FROM (
|
||||
SELECT telemetry_id, COUNT(*) AS session_events,
|
||||
ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) AS rn
|
||||
FROM lifecycle
|
||||
GROUP BY telemetry_id
|
||||
)
|
||||
),
|
||||
ci_noise AS (
|
||||
SELECT
|
||||
COUNT(DISTINCT telemetry_id) AS ci_ids_30d,
|
||||
COUNT(DISTINCT CASE WHEN event = 'install' THEN telemetry_id END) AS ci_install_ids,
|
||||
COUNT(DISTINCT CASE WHEN event IN ('session_end', 'session_crash') THEN telemetry_id END) AS ci_lifecycle_ids
|
||||
FROM events
|
||||
INDEXED BY idx_events_event_created_telemetry
|
||||
WHERE event IN ('install', 'session_start', 'turn_end', 'session_end', 'session_crash')
|
||||
AND created_at > datetime('now', '-30 days')
|
||||
AND is_ci = 1
|
||||
),
|
||||
-- Auth failure health: count affected sessions/users, NOT SUM(error_auth_failed).
|
||||
-- Raw sums are dominated by runaway retry loops (one session logged 18k+ auth
|
||||
-- failures pre-breaker), which makes a single broken install look like a
|
||||
-- fleet-wide auth outage. Affected-session/user counts are outlier-resistant.
|
||||
auth_failures AS (
|
||||
SELECT
|
||||
COUNT(*) AS auth_failed_sessions_30d,
|
||||
COUNT(DISTINCT telemetry_id) AS auth_failed_users_30d,
|
||||
MAX(error_auth_failed) AS max_auth_fails_one_session_30d
|
||||
FROM events
|
||||
WHERE event IN ('session_end', 'session_crash')
|
||||
AND error_auth_failed > 0
|
||||
AND created_at > datetime('now', '-30 days')
|
||||
)
|
||||
SELECT
|
||||
install_events,
|
||||
session_starts,
|
||||
session_ends,
|
||||
session_crashes,
|
||||
install_ids,
|
||||
lifecycle_ids,
|
||||
session_start_ids,
|
||||
lifecycle_ids_without_install,
|
||||
meaningful_sessions,
|
||||
meaningful_users_30d,
|
||||
ci_ids_30d,
|
||||
ci_install_ids,
|
||||
ci_lifecycle_ids,
|
||||
max_session_events_one_id,
|
||||
top5_session_events,
|
||||
total_session_events,
|
||||
auth_failed_sessions_30d,
|
||||
auth_failed_users_30d,
|
||||
max_auth_fails_one_session_30d,
|
||||
ROUND(CAST(session_ends + session_crashes AS REAL) / NULLIF(session_starts, 0), 3) AS lifecycle_completion_ratio
|
||||
FROM event_counts, identity_counts, meaningful, outliers, ci_noise, auth_failures;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Expand early telemetry schema to match the current worker/client payload.
|
||||
-- Safe to run against an already-migrated database: duplicate-column errors
|
||||
-- indicate the column is already present.
|
||||
|
||||
ALTER TABLE events ADD COLUMN had_user_prompt INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN had_assistant_response INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN assistant_responses INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN tool_calls INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN tool_failures INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN resumed_session INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN end_reason TEXT;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Safe to run against an already-migrated database: duplicate-column errors
|
||||
-- indicate the column is already present.
|
||||
|
||||
ALTER TABLE events ADD COLUMN transport_https INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN transport_persistent_ws_fresh INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN transport_persistent_ws_reuse INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN transport_cli_subprocess INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN transport_native_http2 INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN transport_other INTEGER DEFAULT 0;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Safe to run against an already-migrated database: duplicate-column errors
|
||||
-- indicate the column is already present.
|
||||
|
||||
ALTER TABLE events ADD COLUMN duration_secs INTEGER;
|
||||
ALTER TABLE events ADD COLUMN first_assistant_response_ms INTEGER;
|
||||
ALTER TABLE events ADD COLUMN first_tool_call_ms INTEGER;
|
||||
ALTER TABLE events ADD COLUMN first_tool_success_ms INTEGER;
|
||||
ALTER TABLE events ADD COLUMN executed_tool_calls INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN executed_tool_successes INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN executed_tool_failures INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN tool_latency_total_ms INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN tool_latency_max_ms INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN file_write_calls INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN tests_run INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN tests_passed INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN feature_memory_used INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN feature_swarm_used INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN feature_web_used INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN feature_email_used INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN feature_mcp_used INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN feature_side_panel_used INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN feature_goal_used INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN feature_selfdev_used INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN feature_background_used INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN feature_subagent_used INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN unique_mcp_servers INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN session_success INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN abandoned_before_response INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN auth_provider TEXT;
|
||||
ALTER TABLE events ADD COLUMN auth_method TEXT;
|
||||
ALTER TABLE events ADD COLUMN from_version TEXT;
|
||||
@@ -0,0 +1,68 @@
|
||||
-- Phase 1/2/3 telemetry enrichment using a split schema.
|
||||
-- Keep the core `events` table compact enough for D1, and store the
|
||||
-- wider Phase 2/3 per-session analytics in `session_details` keyed by event_id.
|
||||
-- Safe to run against an already-migrated database: duplicate-column errors
|
||||
-- indicate the column is already present.
|
||||
|
||||
ALTER TABLE events ADD COLUMN event_id TEXT;
|
||||
ALTER TABLE events ADD COLUMN session_id TEXT;
|
||||
ALTER TABLE events ADD COLUMN schema_version INTEGER DEFAULT 1;
|
||||
ALTER TABLE events ADD COLUMN build_channel TEXT;
|
||||
ALTER TABLE events ADD COLUMN is_git_checkout INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN is_ci INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN ran_from_cargo INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN step TEXT;
|
||||
ALTER TABLE events ADD COLUMN milestone_elapsed_ms INTEGER;
|
||||
ALTER TABLE events ADD COLUMN feedback_rating TEXT;
|
||||
ALTER TABLE events ADD COLUMN feedback_reason TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_event_id ON events(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_session_id ON events(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_step ON events(step);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_feedback_rating ON events(feedback_rating);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_details (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
first_file_edit_ms INTEGER,
|
||||
first_test_pass_ms INTEGER,
|
||||
tool_cat_read_search INTEGER DEFAULT 0,
|
||||
tool_cat_write INTEGER DEFAULT 0,
|
||||
tool_cat_shell INTEGER DEFAULT 0,
|
||||
tool_cat_web INTEGER DEFAULT 0,
|
||||
tool_cat_memory INTEGER DEFAULT 0,
|
||||
tool_cat_subagent INTEGER DEFAULT 0,
|
||||
tool_cat_swarm INTEGER DEFAULT 0,
|
||||
tool_cat_email INTEGER DEFAULT 0,
|
||||
tool_cat_side_panel INTEGER DEFAULT 0,
|
||||
tool_cat_goal INTEGER DEFAULT 0,
|
||||
tool_cat_mcp INTEGER DEFAULT 0,
|
||||
tool_cat_other INTEGER DEFAULT 0,
|
||||
command_login_used INTEGER DEFAULT 0,
|
||||
command_model_used INTEGER DEFAULT 0,
|
||||
command_usage_used INTEGER DEFAULT 0,
|
||||
command_resume_used INTEGER DEFAULT 0,
|
||||
command_memory_used INTEGER DEFAULT 0,
|
||||
command_swarm_used INTEGER DEFAULT 0,
|
||||
command_goal_used INTEGER DEFAULT 0,
|
||||
command_selfdev_used INTEGER DEFAULT 0,
|
||||
command_feedback_used INTEGER DEFAULT 0,
|
||||
command_other_used INTEGER DEFAULT 0,
|
||||
workflow_chat_only INTEGER DEFAULT 0,
|
||||
workflow_coding_used INTEGER DEFAULT 0,
|
||||
workflow_research_used INTEGER DEFAULT 0,
|
||||
workflow_tests_used INTEGER DEFAULT 0,
|
||||
workflow_background_used INTEGER DEFAULT 0,
|
||||
workflow_subagent_used INTEGER DEFAULT 0,
|
||||
workflow_swarm_used INTEGER DEFAULT 0,
|
||||
project_repo_present INTEGER DEFAULT 0,
|
||||
project_lang_rust INTEGER DEFAULT 0,
|
||||
project_lang_js_ts INTEGER DEFAULT 0,
|
||||
project_lang_python INTEGER DEFAULT 0,
|
||||
project_lang_go INTEGER DEFAULT 0,
|
||||
project_lang_markdown INTEGER DEFAULT 0,
|
||||
project_lang_mixed INTEGER DEFAULT 0,
|
||||
days_since_install INTEGER,
|
||||
active_days_7d INTEGER DEFAULT 0,
|
||||
active_days_30d INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (event_id) REFERENCES events(event_id)
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Workflow cadence and per-turn telemetry expansion.
|
||||
-- Safe to re-run against a partially migrated database: duplicate-column errors
|
||||
-- indicate the column already exists.
|
||||
|
||||
ALTER TABLE events ADD COLUMN session_start_hour_utc INTEGER;
|
||||
ALTER TABLE events ADD COLUMN session_start_weekday_utc INTEGER;
|
||||
ALTER TABLE events ADD COLUMN session_end_hour_utc INTEGER;
|
||||
ALTER TABLE events ADD COLUMN session_end_weekday_utc INTEGER;
|
||||
ALTER TABLE events ADD COLUMN previous_session_gap_secs INTEGER;
|
||||
ALTER TABLE events ADD COLUMN sessions_started_24h INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN sessions_started_7d INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN active_sessions_at_start INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN other_active_sessions_at_start INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN max_concurrent_sessions INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN multi_sessioned INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN turn_index INTEGER;
|
||||
ALTER TABLE events ADD COLUMN turn_started_ms INTEGER;
|
||||
ALTER TABLE events ADD COLUMN turn_active_duration_ms INTEGER;
|
||||
ALTER TABLE events ADD COLUMN idle_before_turn_ms INTEGER;
|
||||
ALTER TABLE events ADD COLUMN idle_after_turn_ms INTEGER;
|
||||
ALTER TABLE events ADD COLUMN turn_success INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN turn_abandoned INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN turn_end_reason TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_turn_index ON events(turn_index);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_session_start_hour_utc ON events(session_start_hour_utc);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_multi_sessioned ON events(multi_sessioned);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS turn_details (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
assistant_responses INTEGER DEFAULT 0,
|
||||
first_assistant_response_ms INTEGER,
|
||||
first_tool_call_ms INTEGER,
|
||||
first_tool_success_ms INTEGER,
|
||||
first_file_edit_ms INTEGER,
|
||||
first_test_pass_ms INTEGER,
|
||||
tool_calls INTEGER DEFAULT 0,
|
||||
tool_failures INTEGER DEFAULT 0,
|
||||
executed_tool_calls INTEGER DEFAULT 0,
|
||||
executed_tool_successes INTEGER DEFAULT 0,
|
||||
executed_tool_failures INTEGER DEFAULT 0,
|
||||
tool_latency_total_ms INTEGER DEFAULT 0,
|
||||
tool_latency_max_ms INTEGER DEFAULT 0,
|
||||
file_write_calls INTEGER DEFAULT 0,
|
||||
tests_run INTEGER DEFAULT 0,
|
||||
tests_passed INTEGER DEFAULT 0,
|
||||
feature_memory_used INTEGER DEFAULT 0,
|
||||
feature_swarm_used INTEGER DEFAULT 0,
|
||||
feature_web_used INTEGER DEFAULT 0,
|
||||
feature_email_used INTEGER DEFAULT 0,
|
||||
feature_mcp_used INTEGER DEFAULT 0,
|
||||
feature_side_panel_used INTEGER DEFAULT 0,
|
||||
feature_goal_used INTEGER DEFAULT 0,
|
||||
feature_selfdev_used INTEGER DEFAULT 0,
|
||||
feature_background_used INTEGER DEFAULT 0,
|
||||
feature_subagent_used INTEGER DEFAULT 0,
|
||||
unique_mcp_servers INTEGER DEFAULT 0,
|
||||
tool_cat_read_search INTEGER DEFAULT 0,
|
||||
tool_cat_write INTEGER DEFAULT 0,
|
||||
tool_cat_shell INTEGER DEFAULT 0,
|
||||
tool_cat_web INTEGER DEFAULT 0,
|
||||
tool_cat_memory INTEGER DEFAULT 0,
|
||||
tool_cat_subagent INTEGER DEFAULT 0,
|
||||
tool_cat_swarm INTEGER DEFAULT 0,
|
||||
tool_cat_email INTEGER DEFAULT 0,
|
||||
tool_cat_side_panel INTEGER DEFAULT 0,
|
||||
tool_cat_goal INTEGER DEFAULT 0,
|
||||
tool_cat_mcp INTEGER DEFAULT 0,
|
||||
tool_cat_other INTEGER DEFAULT 0,
|
||||
workflow_chat_only INTEGER DEFAULT 0,
|
||||
workflow_coding_used INTEGER DEFAULT 0,
|
||||
workflow_research_used INTEGER DEFAULT 0,
|
||||
workflow_tests_used INTEGER DEFAULT 0,
|
||||
workflow_background_used INTEGER DEFAULT 0,
|
||||
workflow_subagent_used INTEGER DEFAULT 0,
|
||||
workflow_swarm_used INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (event_id) REFERENCES events(event_id)
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE events ADD COLUMN input_tokens INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN output_tokens INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN cache_read_input_tokens INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN cache_creation_input_tokens INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN total_tokens INTEGER DEFAULT 0;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Composite indexes for telemetry dashboard/read-heavy queries.
|
||||
-- D1/SQLite can use these to satisfy event + time filters and distinct telemetry_id
|
||||
-- counts without repeatedly scanning the full events table.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_created_telemetry
|
||||
ON events(event, created_at, telemetry_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_telemetry_created
|
||||
ON events(event, telemetry_id, created_at);
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Agent-time, autonomy, and churn/pain attribution telemetry.
|
||||
|
||||
-- Session-level agent-hours and pain/churn attribution.
|
||||
ALTER TABLE events ADD COLUMN session_stop_reason TEXT;
|
||||
ALTER TABLE events ADD COLUMN agent_role TEXT;
|
||||
ALTER TABLE events ADD COLUMN parent_session_id TEXT;
|
||||
ALTER TABLE events ADD COLUMN agent_active_ms_total INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN agent_model_ms_total INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN agent_tool_ms_total INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN session_idle_ms_total INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN agent_blocked_ms_total INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN time_to_first_agent_action_ms INTEGER;
|
||||
ALTER TABLE events ADD COLUMN time_to_first_useful_action_ms INTEGER;
|
||||
ALTER TABLE events ADD COLUMN spawned_agent_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN background_task_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN background_task_completed_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN subagent_task_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN subagent_success_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN swarm_task_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN swarm_success_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN user_cancelled_count INTEGER DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_session_stop_reason ON events(session_stop_reason);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_agent_role ON events(agent_role);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS turn_details (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
assistant_responses INTEGER DEFAULT 0,
|
||||
first_assistant_response_ms INTEGER,
|
||||
first_tool_call_ms INTEGER,
|
||||
first_tool_success_ms INTEGER,
|
||||
first_file_edit_ms INTEGER,
|
||||
first_test_pass_ms INTEGER,
|
||||
tool_calls INTEGER DEFAULT 0,
|
||||
tool_failures INTEGER DEFAULT 0,
|
||||
executed_tool_calls INTEGER DEFAULT 0,
|
||||
executed_tool_successes INTEGER DEFAULT 0,
|
||||
executed_tool_failures INTEGER DEFAULT 0,
|
||||
tool_latency_total_ms INTEGER DEFAULT 0,
|
||||
tool_latency_max_ms INTEGER DEFAULT 0,
|
||||
file_write_calls INTEGER DEFAULT 0,
|
||||
tests_run INTEGER DEFAULT 0,
|
||||
tests_passed INTEGER DEFAULT 0,
|
||||
feature_memory_used INTEGER DEFAULT 0,
|
||||
feature_swarm_used INTEGER DEFAULT 0,
|
||||
feature_web_used INTEGER DEFAULT 0,
|
||||
feature_email_used INTEGER DEFAULT 0,
|
||||
feature_mcp_used INTEGER DEFAULT 0,
|
||||
feature_side_panel_used INTEGER DEFAULT 0,
|
||||
feature_goal_used INTEGER DEFAULT 0,
|
||||
feature_selfdev_used INTEGER DEFAULT 0,
|
||||
feature_background_used INTEGER DEFAULT 0,
|
||||
feature_subagent_used INTEGER DEFAULT 0,
|
||||
unique_mcp_servers INTEGER DEFAULT 0,
|
||||
tool_cat_read_search INTEGER DEFAULT 0,
|
||||
tool_cat_write INTEGER DEFAULT 0,
|
||||
tool_cat_shell INTEGER DEFAULT 0,
|
||||
tool_cat_web INTEGER DEFAULT 0,
|
||||
tool_cat_memory INTEGER DEFAULT 0,
|
||||
tool_cat_subagent INTEGER DEFAULT 0,
|
||||
tool_cat_swarm INTEGER DEFAULT 0,
|
||||
tool_cat_email INTEGER DEFAULT 0,
|
||||
tool_cat_side_panel INTEGER DEFAULT 0,
|
||||
tool_cat_goal INTEGER DEFAULT 0,
|
||||
tool_cat_mcp INTEGER DEFAULT 0,
|
||||
tool_cat_other INTEGER DEFAULT 0,
|
||||
workflow_chat_only INTEGER DEFAULT 0,
|
||||
workflow_coding_used INTEGER DEFAULT 0,
|
||||
workflow_research_used INTEGER DEFAULT 0,
|
||||
workflow_tests_used INTEGER DEFAULT 0,
|
||||
workflow_background_used INTEGER DEFAULT 0,
|
||||
workflow_subagent_used INTEGER DEFAULT 0,
|
||||
workflow_swarm_used INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (event_id) REFERENCES events(event_id)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE events ADD COLUMN feedback_text TEXT;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Materialized daily active-user rollup for cheap, accurate DAU/WAU/MAU queries.
|
||||
-- One row per UTC day and anonymous telemetry_id. Counters are incremented only
|
||||
-- after the raw event row is inserted, so duplicate event_id retries are ignored.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_active_users (
|
||||
activity_date TEXT NOT NULL,
|
||||
telemetry_id TEXT NOT NULL,
|
||||
first_seen_at TEXT DEFAULT (datetime('now')),
|
||||
last_seen_at TEXT DEFAULT (datetime('now')),
|
||||
raw_active INTEGER DEFAULT 0,
|
||||
meaningful_active INTEGER DEFAULT 0,
|
||||
release_active INTEGER DEFAULT 0,
|
||||
meaningful_release_active INTEGER DEFAULT 0,
|
||||
session_start_count INTEGER DEFAULT 0,
|
||||
turn_end_count INTEGER DEFAULT 0,
|
||||
session_end_count INTEGER DEFAULT 0,
|
||||
session_crash_count INTEGER DEFAULT 0,
|
||||
last_build_channel TEXT,
|
||||
PRIMARY KEY (activity_date, telemetry_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_active_date
|
||||
ON daily_active_users(activity_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_active_date_release
|
||||
ON daily_active_users(activity_date, release_active, meaningful_release_active);
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Backfill recent daily active-user rollups from canonical raw events.
|
||||
-- Limited to the last 35 days to keep D1 work bounded while making DAU/WAU/MAU
|
||||
-- dashboards immediately useful after the rollup table is introduced.
|
||||
|
||||
INSERT INTO daily_active_users (
|
||||
activity_date,
|
||||
telemetry_id,
|
||||
first_seen_at,
|
||||
last_seen_at,
|
||||
raw_active,
|
||||
meaningful_active,
|
||||
release_active,
|
||||
meaningful_release_active,
|
||||
session_start_count,
|
||||
turn_end_count,
|
||||
session_end_count,
|
||||
session_crash_count,
|
||||
last_build_channel
|
||||
)
|
||||
SELECT
|
||||
date(created_at) AS activity_date,
|
||||
telemetry_id,
|
||||
MIN(created_at) AS first_seen_at,
|
||||
MAX(created_at) AS last_seen_at,
|
||||
1 AS raw_active,
|
||||
MAX(CASE WHEN (
|
||||
event IN ('session_end', 'session_crash') AND (
|
||||
turns > 0 OR had_user_prompt > 0 OR had_assistant_response > 0
|
||||
OR assistant_responses > 0 OR tool_calls > 0 OR executed_tool_calls > 0
|
||||
OR duration_secs > 0 OR error_provider_timeout > 0 OR error_auth_failed > 0
|
||||
OR error_tool_error > 0 OR error_mcp_error > 0 OR error_rate_limited > 0
|
||||
OR provider_switches > 0 OR model_switches > 0
|
||||
)
|
||||
) OR (
|
||||
event = 'turn_end' AND (
|
||||
assistant_responses > 0 OR tool_calls > 0 OR executed_tool_calls > 0
|
||||
OR file_write_calls > 0 OR tests_run > 0 OR turn_success > 0
|
||||
)
|
||||
) THEN 1 ELSE 0 END) AS meaningful_active,
|
||||
MAX(CASE WHEN build_channel = 'release' THEN 1 ELSE 0 END) AS release_active,
|
||||
MAX(CASE WHEN build_channel = 'release' AND (
|
||||
(event IN ('session_end', 'session_crash') AND (
|
||||
turns > 0 OR had_user_prompt > 0 OR had_assistant_response > 0
|
||||
OR assistant_responses > 0 OR tool_calls > 0 OR executed_tool_calls > 0
|
||||
OR duration_secs > 0 OR error_provider_timeout > 0 OR error_auth_failed > 0
|
||||
OR error_tool_error > 0 OR error_mcp_error > 0 OR error_rate_limited > 0
|
||||
OR provider_switches > 0 OR model_switches > 0
|
||||
))
|
||||
OR (event = 'turn_end' AND (
|
||||
assistant_responses > 0 OR tool_calls > 0 OR executed_tool_calls > 0
|
||||
OR file_write_calls > 0 OR tests_run > 0 OR turn_success > 0
|
||||
))
|
||||
) THEN 1 ELSE 0 END) AS meaningful_release_active,
|
||||
SUM(CASE WHEN event = 'session_start' THEN 1 ELSE 0 END) AS session_start_count,
|
||||
SUM(CASE WHEN event = 'turn_end' THEN 1 ELSE 0 END) AS turn_end_count,
|
||||
SUM(CASE WHEN event = 'session_end' THEN 1 ELSE 0 END) AS session_end_count,
|
||||
SUM(CASE WHEN event = 'session_crash' THEN 1 ELSE 0 END) AS session_crash_count,
|
||||
MAX(build_channel) AS last_build_channel
|
||||
FROM events INDEXED BY idx_events_event_created_telemetry
|
||||
WHERE event IN ('session_start', 'turn_end', 'session_end', 'session_crash')
|
||||
AND created_at > datetime('now', '-35 days')
|
||||
GROUP BY date(created_at), telemetry_id
|
||||
ON CONFLICT(activity_date, telemetry_id) DO UPDATE SET
|
||||
first_seen_at = MIN(daily_active_users.first_seen_at, excluded.first_seen_at),
|
||||
last_seen_at = MAX(daily_active_users.last_seen_at, excluded.last_seen_at),
|
||||
raw_active = 1,
|
||||
meaningful_active = MAX(daily_active_users.meaningful_active, excluded.meaningful_active),
|
||||
release_active = MAX(daily_active_users.release_active, excluded.release_active),
|
||||
meaningful_release_active = MAX(daily_active_users.meaningful_release_active, excluded.meaningful_release_active),
|
||||
session_start_count = excluded.session_start_count,
|
||||
turn_end_count = excluded.turn_end_count,
|
||||
session_end_count = excluded.session_end_count,
|
||||
session_crash_count = excluded.session_crash_count,
|
||||
last_build_channel = COALESCE(excluded.last_build_channel, daily_active_users.last_build_channel);
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Add CI / dev-environment attribution to the daily active-user rollup so the
|
||||
-- headline DAU/WAU/MAU metrics can exclude automated CI traffic cheaply.
|
||||
--
|
||||
-- Ephemeral CI runners mint a fresh telemetry_id every job, so unfiltered they
|
||||
-- look like brand-new users (and brand-new installs), inflating active-user and
|
||||
-- install counts and depressing retention. We keep the raw rows for transparency
|
||||
-- and crash visibility, but tag them so product dashboards can filter is_ci = 0.
|
||||
|
||||
ALTER TABLE daily_active_users ADD COLUMN ci_active INTEGER DEFAULT 0;
|
||||
ALTER TABLE daily_active_users ADD COLUMN last_is_ci INTEGER DEFAULT 0;
|
||||
|
||||
-- Index to make "real (non-CI) release users today" cheap.
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_active_date_ci
|
||||
ON daily_active_users(activity_date, last_is_ci, meaningful_release_active);
|
||||
|
||||
-- Best-effort backfill from canonical raw events for the last 35 days. A day is
|
||||
-- marked CI if any of that id's lifecycle events that day were emitted under CI.
|
||||
UPDATE daily_active_users
|
||||
SET ci_active = 1, last_is_ci = 1
|
||||
WHERE (activity_date, telemetry_id) IN (
|
||||
SELECT date(created_at), telemetry_id
|
||||
FROM events INDEXED BY idx_events_event_created_telemetry
|
||||
WHERE event IN ('session_start', 'turn_end', 'session_end', 'session_crash')
|
||||
AND created_at > datetime('now', '-35 days')
|
||||
AND is_ci = 1
|
||||
GROUP BY date(created_at), telemetry_id
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Move schema-v5 per-turn and session-cadence fields into the detail tables.
|
||||
--
|
||||
-- Production D1 `events` is at 96 columns and D1 caps tables at 100 columns,
|
||||
-- so migration 0005's ALTER TABLE events additions (turn_index, turn timings,
|
||||
-- turn_success, session hour/cadence fields, ...) can never apply there:
|
||||
-- `too many columns on sqlite_altertab_events`. The worker filters inserts by
|
||||
-- the live column set, so these client-sent fields were silently dropped.
|
||||
-- The detail tables have plenty of headroom, so record the fields there.
|
||||
|
||||
ALTER TABLE turn_details ADD COLUMN turn_index INTEGER;
|
||||
ALTER TABLE turn_details ADD COLUMN turn_started_ms INTEGER;
|
||||
ALTER TABLE turn_details ADD COLUMN turn_active_duration_ms INTEGER;
|
||||
ALTER TABLE turn_details ADD COLUMN idle_before_turn_ms INTEGER;
|
||||
ALTER TABLE turn_details ADD COLUMN idle_after_turn_ms INTEGER;
|
||||
ALTER TABLE turn_details ADD COLUMN turn_success INTEGER DEFAULT 0;
|
||||
ALTER TABLE turn_details ADD COLUMN turn_abandoned INTEGER DEFAULT 0;
|
||||
ALTER TABLE turn_details ADD COLUMN turn_end_reason TEXT;
|
||||
ALTER TABLE turn_details ADD COLUMN input_tokens INTEGER DEFAULT 0;
|
||||
ALTER TABLE turn_details ADD COLUMN output_tokens INTEGER DEFAULT 0;
|
||||
ALTER TABLE turn_details ADD COLUMN total_tokens INTEGER DEFAULT 0;
|
||||
|
||||
ALTER TABLE session_details ADD COLUMN session_start_hour_utc INTEGER;
|
||||
ALTER TABLE session_details ADD COLUMN session_start_weekday_utc INTEGER;
|
||||
ALTER TABLE session_details ADD COLUMN session_end_hour_utc INTEGER;
|
||||
ALTER TABLE session_details ADD COLUMN session_end_weekday_utc INTEGER;
|
||||
ALTER TABLE session_details ADD COLUMN previous_session_gap_secs INTEGER;
|
||||
ALTER TABLE session_details ADD COLUMN sessions_started_24h INTEGER DEFAULT 0;
|
||||
ALTER TABLE session_details ADD COLUMN sessions_started_7d INTEGER DEFAULT 0;
|
||||
ALTER TABLE session_details ADD COLUMN active_sessions_at_start INTEGER DEFAULT 0;
|
||||
ALTER TABLE session_details ADD COLUMN other_active_sessions_at_start INTEGER DEFAULT 0;
|
||||
ALTER TABLE session_details ADD COLUMN max_concurrent_sessions INTEGER DEFAULT 0;
|
||||
ALTER TABLE session_details ADD COLUMN multi_sessioned INTEGER DEFAULT 0;
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Backfill the daily_active_users rollup across ALL history, not just the
|
||||
-- 35-day window used by 0011, and extend CI attribution the same way.
|
||||
--
|
||||
-- This matters because raw high-volume events (turn_end, session_start,
|
||||
-- onboarding_step) are now pruned on a retention schedule to stay under the
|
||||
-- D1 500 MB hard cap. The rollup table is the durable, compact record of
|
||||
-- daily activity, so it must cover the full history before pruning erodes
|
||||
-- the raw rows it is derived from. Long-retention lifecycle events
|
||||
-- (session_end / session_crash, kept 12 months) still provide the meaningful
|
||||
-- signal for old dates.
|
||||
|
||||
INSERT INTO daily_active_users (
|
||||
activity_date,
|
||||
telemetry_id,
|
||||
first_seen_at,
|
||||
last_seen_at,
|
||||
raw_active,
|
||||
meaningful_active,
|
||||
release_active,
|
||||
meaningful_release_active,
|
||||
session_start_count,
|
||||
turn_end_count,
|
||||
session_end_count,
|
||||
session_crash_count,
|
||||
ci_active,
|
||||
last_is_ci,
|
||||
last_build_channel
|
||||
)
|
||||
SELECT
|
||||
date(created_at) AS activity_date,
|
||||
telemetry_id,
|
||||
MIN(created_at) AS first_seen_at,
|
||||
MAX(created_at) AS last_seen_at,
|
||||
1 AS raw_active,
|
||||
MAX(CASE WHEN (
|
||||
event IN ('session_end', 'session_crash') AND (
|
||||
turns > 0 OR had_user_prompt > 0 OR had_assistant_response > 0
|
||||
OR assistant_responses > 0 OR tool_calls > 0 OR executed_tool_calls > 0
|
||||
OR duration_secs > 0 OR error_provider_timeout > 0 OR error_auth_failed > 0
|
||||
OR error_tool_error > 0 OR error_mcp_error > 0 OR error_rate_limited > 0
|
||||
OR provider_switches > 0 OR model_switches > 0
|
||||
)
|
||||
) THEN 1 ELSE 0 END) AS meaningful_active,
|
||||
MAX(CASE WHEN build_channel = 'release' THEN 1 ELSE 0 END) AS release_active,
|
||||
MAX(CASE WHEN build_channel = 'release' AND (
|
||||
event IN ('session_end', 'session_crash') AND (
|
||||
turns > 0 OR had_user_prompt > 0 OR had_assistant_response > 0
|
||||
OR assistant_responses > 0 OR tool_calls > 0 OR executed_tool_calls > 0
|
||||
OR duration_secs > 0 OR error_provider_timeout > 0 OR error_auth_failed > 0
|
||||
OR error_tool_error > 0 OR error_mcp_error > 0 OR error_rate_limited > 0
|
||||
OR provider_switches > 0 OR model_switches > 0
|
||||
)
|
||||
) THEN 1 ELSE 0 END) AS meaningful_release_active,
|
||||
SUM(CASE WHEN event = 'session_start' THEN 1 ELSE 0 END) AS session_start_count,
|
||||
SUM(CASE WHEN event = 'turn_end' THEN 1 ELSE 0 END) AS turn_end_count,
|
||||
SUM(CASE WHEN event = 'session_end' THEN 1 ELSE 0 END) AS session_end_count,
|
||||
SUM(CASE WHEN event = 'session_crash' THEN 1 ELSE 0 END) AS session_crash_count,
|
||||
MAX(is_ci) AS ci_active,
|
||||
MAX(is_ci) AS last_is_ci,
|
||||
MAX(build_channel) AS last_build_channel
|
||||
FROM events INDEXED BY idx_events_event_created_telemetry
|
||||
WHERE event IN ('session_start', 'turn_end', 'session_end', 'session_crash')
|
||||
GROUP BY date(created_at), telemetry_id
|
||||
ON CONFLICT(activity_date, telemetry_id) DO UPDATE SET
|
||||
first_seen_at = MIN(daily_active_users.first_seen_at, excluded.first_seen_at),
|
||||
last_seen_at = MAX(daily_active_users.last_seen_at, excluded.last_seen_at),
|
||||
raw_active = 1,
|
||||
meaningful_active = MAX(daily_active_users.meaningful_active, excluded.meaningful_active),
|
||||
release_active = MAX(daily_active_users.release_active, excluded.release_active),
|
||||
meaningful_release_active = MAX(daily_active_users.meaningful_release_active, excluded.meaningful_release_active),
|
||||
ci_active = MAX(daily_active_users.ci_active, excluded.ci_active),
|
||||
last_build_channel = COALESCE(excluded.last_build_channel, daily_active_users.last_build_channel);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Add auth_failure_reason to events.
|
||||
--
|
||||
-- The client has sent `auth_failure_reason` on `auth_failed` onboarding_step
|
||||
-- events since login diagnostics landed (classify_auth_failure_message), but
|
||||
-- the worker never stored it: the column filter silently dropped the field, so
|
||||
-- auth failures were undiagnosable from the dashboard. Additive and nullable;
|
||||
-- existing rows are untouched.
|
||||
ALTER TABLE events ADD COLUMN auth_failure_reason TEXT;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Website analytics + token subscription plan events (migration 0016).
|
||||
--
|
||||
-- New event types:
|
||||
-- web_pageview, web_cta_click (website beacon, anonymous visitor_id)
|
||||
-- subscription_login, subscription_activated,
|
||||
-- subscription_budget_exhausted, subscription_router_error
|
||||
-- account_linked (telemetry_id <-> account_id join anchor)
|
||||
--
|
||||
-- Column placement: production events sits at 96 of D1's 100-column hard cap
|
||||
-- (see the note in schema.sql and migrations/0013), so only the two columns
|
||||
-- every subscription dashboard query needs (account_id, tier) go on events
|
||||
-- (96 -> 98; scarce headroom remains, spend it carefully). The web-only
|
||||
-- fields live in a web_details detail table keyed by event_id, following the
|
||||
-- session_details / turn_details house pattern. Subscription events that
|
||||
-- carry a model reuse the existing generic events.model_start column (these
|
||||
-- are new event types, so no historical rows are re-interpreted).
|
||||
|
||||
ALTER TABLE events ADD COLUMN account_id TEXT;
|
||||
ALTER TABLE events ADD COLUMN tier TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS web_details (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
path TEXT,
|
||||
referrer TEXT,
|
||||
visitor_id TEXT,
|
||||
utm_source TEXT,
|
||||
utm_medium TEXT,
|
||||
utm_campaign TEXT,
|
||||
cta TEXT,
|
||||
FOREIGN KEY (event_id) REFERENCES events(event_id)
|
||||
);
|
||||
|
||||
-- Indexes for the README dashboard queries.
|
||||
-- Daily web visitors / pricing-page funnel group by visitor_id, path, cta:
|
||||
CREATE INDEX IF NOT EXISTS idx_web_details_visitor_id ON web_details(visitor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_web_details_path ON web_details(path);
|
||||
CREATE INDEX IF NOT EXISTS idx_web_details_cta ON web_details(cta);
|
||||
-- account_linked join + subscription rollups filter/group on these:
|
||||
CREATE INDEX IF NOT EXISTS idx_events_account_id ON events(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_tier_created ON events(event, tier, created_at);
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Dedicated detail table for privacy-safe sponsored-discovery telemetry.
|
||||
-- The parent events table is intentionally left unchanged because it is near
|
||||
-- D1's 100-column cap.
|
||||
CREATE TABLE IF NOT EXISTS discovery_details (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
request_id TEXT NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
category TEXT,
|
||||
selected_tool TEXT,
|
||||
outcome TEXT NOT NULL,
|
||||
failure_reason TEXT,
|
||||
http_status INTEGER,
|
||||
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
response_bytes INTEGER,
|
||||
result_count INTEGER,
|
||||
query_present INTEGER NOT NULL DEFAULT 0,
|
||||
reason_present INTEGER NOT NULL DEFAULT 0,
|
||||
custom_endpoint INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (event_id) REFERENCES events(event_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_discovery_request_id ON discovery_details(request_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_phase_outcome ON discovery_details(phase, outcome);
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_category_outcome ON discovery_details(category, outcome);
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_selected_tool ON discovery_details(selected_tool);
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_failure_reason ON discovery_details(failure_reason);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Privacy-safe website performance and error classifications.
|
||||
--
|
||||
-- web_vital stores only a bounded standard metric name/value/rating tuple.
|
||||
-- web_error stores only a coarse error kind. Error messages, stacks, and URLs
|
||||
-- are intentionally not represented in the schema.
|
||||
|
||||
ALTER TABLE web_details ADD COLUMN metric_name TEXT;
|
||||
ALTER TABLE web_details ADD COLUMN metric_value REAL;
|
||||
ALTER TABLE web_details ADD COLUMN rating TEXT;
|
||||
ALTER TABLE web_details ADD COLUMN error_kind TEXT;
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "jcode-telemetry",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx wrangler dev",
|
||||
"test": "node --test test/worker.test.mjs",
|
||||
"deploy": "npx wrangler deploy",
|
||||
"migrate:expand": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0001_expand_events.sql",
|
||||
"migrate:transport": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0002_transport_metrics.sql",
|
||||
"migrate:usage": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0003_usage_expansion.sql",
|
||||
"migrate:phase123": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0004_telemetry_phase123.sql",
|
||||
"migrate:workflow": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0005_workflow_turn_telemetry.sql",
|
||||
"migrate:tokens": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0006_token_usage.sql",
|
||||
"migrate:dashboard-indexes": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0007_dashboard_indexes.sql",
|
||||
"migrate:agent-time": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0008_agent_time_and_churn.sql",
|
||||
"migrate:feedback-text": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0009_feedback_text.sql",
|
||||
"migrate:daily-active": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0010_daily_active_users.sql",
|
||||
"migrate:daily-active-backfill": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0011_backfill_daily_active_recent.sql",
|
||||
"migrate:daily-active-ci": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0012_daily_active_ci_flag.sql",
|
||||
"health": "npx wrangler d1 execute jcode-telemetry --remote --file=health.sql",
|
||||
"health:size": "curl -s https://jcode-telemetry.jeremyhuang55555.workers.dev/v1/health",
|
||||
"dau": "npx wrangler d1 execute jcode-telemetry --remote --file=dau.sql",
|
||||
"users": "npx wrangler d1 execute jcode-telemetry --remote --file=users.sql",
|
||||
"migrate:detail-fields": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0013_detail_table_turn_session_fields.sql",
|
||||
"migrate:dau-full-backfill": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0014_full_history_dau_backfill.sql",
|
||||
"migrate:auth-failure-reason": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0015_auth_failure_reason.sql",
|
||||
"migrate:web-subscription": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0016_web_subscription_analytics.sql",
|
||||
"migrate:discovery": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0017_discovery_telemetry.sql",
|
||||
"migrate:web-quality": "npx wrangler d1 execute jcode-telemetry --remote --file=migrations/0018_web_quality_telemetry.sql"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
-- Schema for jcode telemetry D1 database
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
telemetry_id TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
os TEXT NOT NULL,
|
||||
arch TEXT NOT NULL,
|
||||
provider_start TEXT,
|
||||
provider_end TEXT,
|
||||
model_start TEXT,
|
||||
model_end TEXT,
|
||||
provider_switches INTEGER DEFAULT 0,
|
||||
model_switches INTEGER DEFAULT 0,
|
||||
duration_mins INTEGER,
|
||||
duration_secs INTEGER,
|
||||
turns INTEGER,
|
||||
had_user_prompt INTEGER DEFAULT 0,
|
||||
had_assistant_response INTEGER DEFAULT 0,
|
||||
assistant_responses INTEGER DEFAULT 0,
|
||||
first_assistant_response_ms INTEGER,
|
||||
first_tool_call_ms INTEGER,
|
||||
first_tool_success_ms INTEGER,
|
||||
tool_calls INTEGER DEFAULT 0,
|
||||
tool_failures INTEGER DEFAULT 0,
|
||||
executed_tool_calls INTEGER DEFAULT 0,
|
||||
executed_tool_successes INTEGER DEFAULT 0,
|
||||
executed_tool_failures INTEGER DEFAULT 0,
|
||||
tool_latency_total_ms INTEGER DEFAULT 0,
|
||||
tool_latency_max_ms INTEGER DEFAULT 0,
|
||||
file_write_calls INTEGER DEFAULT 0,
|
||||
tests_run INTEGER DEFAULT 0,
|
||||
tests_passed INTEGER DEFAULT 0,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
cache_read_input_tokens INTEGER DEFAULT 0,
|
||||
cache_creation_input_tokens INTEGER DEFAULT 0,
|
||||
total_tokens INTEGER DEFAULT 0,
|
||||
feature_memory_used INTEGER DEFAULT 0,
|
||||
feature_swarm_used INTEGER DEFAULT 0,
|
||||
feature_web_used INTEGER DEFAULT 0,
|
||||
feature_email_used INTEGER DEFAULT 0,
|
||||
feature_mcp_used INTEGER DEFAULT 0,
|
||||
feature_side_panel_used INTEGER DEFAULT 0,
|
||||
feature_goal_used INTEGER DEFAULT 0,
|
||||
feature_selfdev_used INTEGER DEFAULT 0,
|
||||
feature_background_used INTEGER DEFAULT 0,
|
||||
feature_subagent_used INTEGER DEFAULT 0,
|
||||
unique_mcp_servers INTEGER DEFAULT 0,
|
||||
session_success INTEGER DEFAULT 0,
|
||||
abandoned_before_response INTEGER DEFAULT 0,
|
||||
session_stop_reason TEXT,
|
||||
agent_role TEXT,
|
||||
parent_session_id TEXT,
|
||||
agent_active_ms_total INTEGER DEFAULT 0,
|
||||
agent_model_ms_total INTEGER DEFAULT 0,
|
||||
agent_tool_ms_total INTEGER DEFAULT 0,
|
||||
session_idle_ms_total INTEGER DEFAULT 0,
|
||||
agent_blocked_ms_total INTEGER DEFAULT 0,
|
||||
time_to_first_agent_action_ms INTEGER,
|
||||
time_to_first_useful_action_ms INTEGER,
|
||||
spawned_agent_count INTEGER DEFAULT 0,
|
||||
background_task_count INTEGER DEFAULT 0,
|
||||
background_task_completed_count INTEGER DEFAULT 0,
|
||||
subagent_task_count INTEGER DEFAULT 0,
|
||||
subagent_success_count INTEGER DEFAULT 0,
|
||||
swarm_task_count INTEGER DEFAULT 0,
|
||||
swarm_success_count INTEGER DEFAULT 0,
|
||||
user_cancelled_count INTEGER DEFAULT 0,
|
||||
transport_https INTEGER DEFAULT 0,
|
||||
transport_persistent_ws_fresh INTEGER DEFAULT 0,
|
||||
transport_persistent_ws_reuse INTEGER DEFAULT 0,
|
||||
transport_cli_subprocess INTEGER DEFAULT 0,
|
||||
transport_native_http2 INTEGER DEFAULT 0,
|
||||
transport_other INTEGER DEFAULT 0,
|
||||
resumed_session INTEGER DEFAULT 0,
|
||||
end_reason TEXT,
|
||||
auth_provider TEXT,
|
||||
auth_method TEXT,
|
||||
-- Failure reason label for onboarding_step step='auth_failed' events
|
||||
-- (classify_auth_failure_message labels, e.g. callback_timeout,
|
||||
-- validation_failed, oauth_rate_limited). Added in migration 0015.
|
||||
auth_failure_reason TEXT,
|
||||
from_version TEXT,
|
||||
event_id TEXT,
|
||||
session_id TEXT,
|
||||
schema_version INTEGER DEFAULT 1,
|
||||
build_channel TEXT,
|
||||
is_git_checkout INTEGER DEFAULT 0,
|
||||
is_ci INTEGER DEFAULT 0,
|
||||
ran_from_cargo INTEGER DEFAULT 0,
|
||||
step TEXT,
|
||||
milestone_elapsed_ms INTEGER,
|
||||
feedback_rating TEXT,
|
||||
feedback_reason TEXT,
|
||||
feedback_text TEXT,
|
||||
-- NOTE: schema-v5 per-turn fields (turn_index, turn timings, turn_success,
|
||||
-- turn_abandoned, turn_end_reason) and session cadence fields (hour/weekday,
|
||||
-- previous_session_gap_secs, sessions_started_24h/7d, concurrency) live in
|
||||
-- turn_details / session_details, NOT here. D1 caps tables at 100 columns
|
||||
-- and events sits at 96 in production, so it has no headroom. See
|
||||
-- migrations/0013_detail_table_turn_session_fields.sql.
|
||||
error_provider_timeout INTEGER DEFAULT 0,
|
||||
error_auth_failed INTEGER DEFAULT 0,
|
||||
error_tool_error INTEGER DEFAULT 0,
|
||||
error_mcp_error INTEGER DEFAULT 0,
|
||||
error_rate_limited INTEGER DEFAULT 0,
|
||||
-- Token subscription plan fields (migration 0016). These two are the only
|
||||
-- subscription columns on events because the table is near D1's
|
||||
-- 100-column cap (96 in production before 0016); web-only fields live in
|
||||
-- web_details below.
|
||||
account_id TEXT,
|
||||
tier TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_telemetry_id ON events(telemetry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event ON events(event);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_created_telemetry ON events(event, created_at, telemetry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_telemetry_created ON events(event, telemetry_id, created_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_event_id ON events(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_session_id ON events(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_step ON events(step);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_feedback_rating ON events(feedback_rating);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_account_id ON events(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_tier_created ON events(event, tier, created_at);
|
||||
|
||||
-- Website beacon detail rows (web_pageview / web_cta_click / web_vital /
|
||||
-- web_error), keyed by event_id like session_details / turn_details. Added in
|
||||
-- migration 0016 and extended with privacy-safe quality fields in 0018.
|
||||
CREATE TABLE IF NOT EXISTS web_details (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
path TEXT,
|
||||
referrer TEXT,
|
||||
visitor_id TEXT,
|
||||
utm_source TEXT,
|
||||
utm_medium TEXT,
|
||||
utm_campaign TEXT,
|
||||
cta TEXT,
|
||||
metric_name TEXT,
|
||||
metric_value REAL,
|
||||
rating TEXT,
|
||||
error_kind TEXT,
|
||||
FOREIGN KEY (event_id) REFERENCES events(event_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_web_details_visitor_id ON web_details(visitor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_web_details_path ON web_details(path);
|
||||
CREATE INDEX IF NOT EXISTS idx_web_details_cta ON web_details(cta);
|
||||
|
||||
-- Privacy-safe sponsored-discovery attempt details. Free-text query and reason
|
||||
-- content are never sent by the client and therefore cannot be stored here.
|
||||
CREATE TABLE IF NOT EXISTS discovery_details (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
request_id TEXT NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
category TEXT,
|
||||
selected_tool TEXT,
|
||||
outcome TEXT NOT NULL,
|
||||
failure_reason TEXT,
|
||||
http_status INTEGER,
|
||||
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
response_bytes INTEGER,
|
||||
result_count INTEGER,
|
||||
query_present INTEGER NOT NULL DEFAULT 0,
|
||||
reason_present INTEGER NOT NULL DEFAULT 0,
|
||||
custom_endpoint INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (event_id) REFERENCES events(event_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_discovery_request_id ON discovery_details(request_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_phase_outcome ON discovery_details(phase, outcome);
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_category_outcome ON discovery_details(category, outcome);
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_selected_tool ON discovery_details(selected_tool);
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_failure_reason ON discovery_details(failure_reason);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_details (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
session_start_hour_utc INTEGER,
|
||||
session_start_weekday_utc INTEGER,
|
||||
session_end_hour_utc INTEGER,
|
||||
session_end_weekday_utc INTEGER,
|
||||
previous_session_gap_secs INTEGER,
|
||||
sessions_started_24h INTEGER DEFAULT 0,
|
||||
sessions_started_7d INTEGER DEFAULT 0,
|
||||
active_sessions_at_start INTEGER DEFAULT 0,
|
||||
other_active_sessions_at_start INTEGER DEFAULT 0,
|
||||
max_concurrent_sessions INTEGER DEFAULT 0,
|
||||
multi_sessioned INTEGER DEFAULT 0,
|
||||
first_file_edit_ms INTEGER,
|
||||
first_test_pass_ms INTEGER,
|
||||
tool_cat_read_search INTEGER DEFAULT 0,
|
||||
tool_cat_write INTEGER DEFAULT 0,
|
||||
tool_cat_shell INTEGER DEFAULT 0,
|
||||
tool_cat_web INTEGER DEFAULT 0,
|
||||
tool_cat_memory INTEGER DEFAULT 0,
|
||||
tool_cat_subagent INTEGER DEFAULT 0,
|
||||
tool_cat_swarm INTEGER DEFAULT 0,
|
||||
tool_cat_email INTEGER DEFAULT 0,
|
||||
tool_cat_side_panel INTEGER DEFAULT 0,
|
||||
tool_cat_goal INTEGER DEFAULT 0,
|
||||
tool_cat_mcp INTEGER DEFAULT 0,
|
||||
tool_cat_other INTEGER DEFAULT 0,
|
||||
command_login_used INTEGER DEFAULT 0,
|
||||
command_model_used INTEGER DEFAULT 0,
|
||||
command_usage_used INTEGER DEFAULT 0,
|
||||
command_resume_used INTEGER DEFAULT 0,
|
||||
command_memory_used INTEGER DEFAULT 0,
|
||||
command_swarm_used INTEGER DEFAULT 0,
|
||||
command_goal_used INTEGER DEFAULT 0,
|
||||
command_selfdev_used INTEGER DEFAULT 0,
|
||||
command_feedback_used INTEGER DEFAULT 0,
|
||||
command_other_used INTEGER DEFAULT 0,
|
||||
workflow_chat_only INTEGER DEFAULT 0,
|
||||
workflow_coding_used INTEGER DEFAULT 0,
|
||||
workflow_research_used INTEGER DEFAULT 0,
|
||||
workflow_tests_used INTEGER DEFAULT 0,
|
||||
workflow_background_used INTEGER DEFAULT 0,
|
||||
workflow_subagent_used INTEGER DEFAULT 0,
|
||||
workflow_swarm_used INTEGER DEFAULT 0,
|
||||
project_repo_present INTEGER DEFAULT 0,
|
||||
project_lang_rust INTEGER DEFAULT 0,
|
||||
project_lang_js_ts INTEGER DEFAULT 0,
|
||||
project_lang_python INTEGER DEFAULT 0,
|
||||
project_lang_go INTEGER DEFAULT 0,
|
||||
project_lang_markdown INTEGER DEFAULT 0,
|
||||
project_lang_mixed INTEGER DEFAULT 0,
|
||||
days_since_install INTEGER,
|
||||
active_days_7d INTEGER DEFAULT 0,
|
||||
active_days_30d INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (event_id) REFERENCES events(event_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS turn_details (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
turn_index INTEGER,
|
||||
turn_started_ms INTEGER,
|
||||
turn_active_duration_ms INTEGER,
|
||||
idle_before_turn_ms INTEGER,
|
||||
idle_after_turn_ms INTEGER,
|
||||
turn_success INTEGER DEFAULT 0,
|
||||
turn_abandoned INTEGER DEFAULT 0,
|
||||
turn_end_reason TEXT,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
total_tokens INTEGER DEFAULT 0,
|
||||
assistant_responses INTEGER DEFAULT 0,
|
||||
first_assistant_response_ms INTEGER,
|
||||
first_tool_call_ms INTEGER,
|
||||
first_tool_success_ms INTEGER,
|
||||
first_file_edit_ms INTEGER,
|
||||
first_test_pass_ms INTEGER,
|
||||
tool_calls INTEGER DEFAULT 0,
|
||||
tool_failures INTEGER DEFAULT 0,
|
||||
executed_tool_calls INTEGER DEFAULT 0,
|
||||
executed_tool_successes INTEGER DEFAULT 0,
|
||||
executed_tool_failures INTEGER DEFAULT 0,
|
||||
tool_latency_total_ms INTEGER DEFAULT 0,
|
||||
tool_latency_max_ms INTEGER DEFAULT 0,
|
||||
file_write_calls INTEGER DEFAULT 0,
|
||||
tests_run INTEGER DEFAULT 0,
|
||||
tests_passed INTEGER DEFAULT 0,
|
||||
feature_memory_used INTEGER DEFAULT 0,
|
||||
feature_swarm_used INTEGER DEFAULT 0,
|
||||
feature_web_used INTEGER DEFAULT 0,
|
||||
feature_email_used INTEGER DEFAULT 0,
|
||||
feature_mcp_used INTEGER DEFAULT 0,
|
||||
feature_side_panel_used INTEGER DEFAULT 0,
|
||||
feature_goal_used INTEGER DEFAULT 0,
|
||||
feature_selfdev_used INTEGER DEFAULT 0,
|
||||
feature_background_used INTEGER DEFAULT 0,
|
||||
feature_subagent_used INTEGER DEFAULT 0,
|
||||
unique_mcp_servers INTEGER DEFAULT 0,
|
||||
tool_cat_read_search INTEGER DEFAULT 0,
|
||||
tool_cat_write INTEGER DEFAULT 0,
|
||||
tool_cat_shell INTEGER DEFAULT 0,
|
||||
tool_cat_web INTEGER DEFAULT 0,
|
||||
tool_cat_memory INTEGER DEFAULT 0,
|
||||
tool_cat_subagent INTEGER DEFAULT 0,
|
||||
tool_cat_swarm INTEGER DEFAULT 0,
|
||||
tool_cat_email INTEGER DEFAULT 0,
|
||||
tool_cat_side_panel INTEGER DEFAULT 0,
|
||||
tool_cat_goal INTEGER DEFAULT 0,
|
||||
tool_cat_mcp INTEGER DEFAULT 0,
|
||||
tool_cat_other INTEGER DEFAULT 0,
|
||||
workflow_chat_only INTEGER DEFAULT 0,
|
||||
workflow_coding_used INTEGER DEFAULT 0,
|
||||
workflow_research_used INTEGER DEFAULT 0,
|
||||
workflow_tests_used INTEGER DEFAULT 0,
|
||||
workflow_background_used INTEGER DEFAULT 0,
|
||||
workflow_subagent_used INTEGER DEFAULT 0,
|
||||
workflow_swarm_used INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (event_id) REFERENCES events(event_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_active_users (
|
||||
activity_date TEXT NOT NULL,
|
||||
telemetry_id TEXT NOT NULL,
|
||||
first_seen_at TEXT DEFAULT (datetime('now')),
|
||||
last_seen_at TEXT DEFAULT (datetime('now')),
|
||||
raw_active INTEGER DEFAULT 0,
|
||||
meaningful_active INTEGER DEFAULT 0,
|
||||
release_active INTEGER DEFAULT 0,
|
||||
meaningful_release_active INTEGER DEFAULT 0,
|
||||
session_start_count INTEGER DEFAULT 0,
|
||||
turn_end_count INTEGER DEFAULT 0,
|
||||
session_end_count INTEGER DEFAULT 0,
|
||||
session_crash_count INTEGER DEFAULT 0,
|
||||
ci_active INTEGER DEFAULT 0,
|
||||
last_is_ci INTEGER DEFAULT 0,
|
||||
last_build_channel TEXT,
|
||||
PRIMARY KEY (activity_date, telemetry_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_active_date
|
||||
ON daily_active_users(activity_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_active_date_release
|
||||
ON daily_active_users(activity_date, release_active, meaningful_release_active);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_active_date_ci
|
||||
ON daily_active_users(activity_date, last_is_ci, meaningful_release_active);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,672 @@
|
||||
// Tests for the telemetry worker's dual-write + D1 self-defense behavior.
|
||||
// Run with: node --test test/
|
||||
//
|
||||
// The worker module is plain ESM with injected bindings (env.DB, env.FIREHOSE),
|
||||
// so it can be exercised without wrangler by passing mocks.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import worker from "../src/worker.js";
|
||||
|
||||
const EVENT_URL = "https://telemetry.example/v1/event";
|
||||
const HEALTH_URL = "https://telemetry.example/v1/health";
|
||||
|
||||
function makeBody(overrides = {}) {
|
||||
return {
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
event: "onboarding_step",
|
||||
version: "0.0.0-test",
|
||||
os: "linux",
|
||||
arch: "x86_64",
|
||||
step: "auth_failed",
|
||||
auth_provider: "testprov",
|
||||
auth_method: "oauth",
|
||||
auth_failure_reason: "callback_timeout",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDiscoveryBody(overrides = {}) {
|
||||
return makeBody({
|
||||
event: "discovery",
|
||||
event_id: "discovery-event-1",
|
||||
session_id: "session-1",
|
||||
request_id: "11111111-2222-4333-8444-555555555555",
|
||||
phase: "browse",
|
||||
category: "payments",
|
||||
selected_tool: null,
|
||||
outcome: "success",
|
||||
failure_reason: null,
|
||||
http_status: 200,
|
||||
latency_ms: 125,
|
||||
response_bytes: 2048,
|
||||
result_count: 3,
|
||||
query_present: true,
|
||||
reason_present: true,
|
||||
custom_endpoint: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function postRequest(body, url = EVENT_URL) {
|
||||
return new Request(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// Minimal D1 mock. `plan` lets tests fail specific statements or set the
|
||||
// reported database size.
|
||||
function makeDb(plan = {}) {
|
||||
const executed = [];
|
||||
const sizeAfter = plan.sizeAfter ?? 1000;
|
||||
return {
|
||||
executed,
|
||||
prepare(sql) {
|
||||
return {
|
||||
bind(...values) {
|
||||
return {
|
||||
async run() {
|
||||
executed.push({ sql, values });
|
||||
if (plan.failInserts && /^INSERT/i.test(sql.trim())) {
|
||||
throw new Error(plan.failureMessage || "generic transient error");
|
||||
}
|
||||
return { meta: { changes: 1, size_after: sizeAfter } };
|
||||
},
|
||||
async all() {
|
||||
executed.push({ sql, values });
|
||||
return { results: [] };
|
||||
},
|
||||
};
|
||||
},
|
||||
async run() {
|
||||
executed.push({ sql, values: [] });
|
||||
return { meta: { changes: 0, size_after: sizeAfter } };
|
||||
},
|
||||
async all() {
|
||||
executed.push({ sql, values: [] });
|
||||
// PRAGMA table_info: report every column the worker may reference.
|
||||
if (/table_info\(web_details\)/.test(sql)) {
|
||||
return {
|
||||
results: [
|
||||
"event_id", "path", "referrer", "visitor_id", "utm_source",
|
||||
"utm_medium", "utm_campaign", "cta", "metric_name",
|
||||
"metric_value", "rating", "error_kind",
|
||||
].map((name) => ({ name })),
|
||||
};
|
||||
}
|
||||
if (/table_info\(discovery_details\)/.test(sql)) {
|
||||
return {
|
||||
results: [
|
||||
"event_id", "request_id", "phase", "category", "selected_tool",
|
||||
"outcome", "failure_reason", "http_status", "latency_ms",
|
||||
"response_bytes", "result_count", "query_present",
|
||||
"reason_present", "custom_endpoint",
|
||||
].map((name) => ({ name })),
|
||||
};
|
||||
}
|
||||
if (/table_info/.test(sql)) {
|
||||
return {
|
||||
results: [
|
||||
"telemetry_id", "event", "version", "os", "arch", "step",
|
||||
"auth_provider", "auth_method", "auth_failure_reason",
|
||||
"milestone_elapsed_ms", "event_id", "session_id",
|
||||
"schema_version", "build_channel", "is_git_checkout", "is_ci",
|
||||
"ran_from_cargo", "account_id", "tier", "model_start",
|
||||
].map((name) => ({ name })),
|
||||
};
|
||||
}
|
||||
return { results: [] };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeFirehose() {
|
||||
const points = [];
|
||||
return {
|
||||
points,
|
||||
writeDataPoint(point) {
|
||||
points.push(point);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx() {
|
||||
const waited = [];
|
||||
return {
|
||||
waited,
|
||||
waitUntil(promise) {
|
||||
waited.push(promise);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("event is dual-written: firehose point + D1 insert", async () => {
|
||||
const db = makeDb();
|
||||
const firehose = makeFirehose();
|
||||
const ctx = makeCtx();
|
||||
|
||||
const response = await worker.fetch(postRequest(makeBody()), { DB: db, FIREHOSE: firehose }, ctx);
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.durable, true);
|
||||
assert.equal(json.firehose, true);
|
||||
|
||||
assert.equal(firehose.points.length, 1);
|
||||
const point = firehose.points[0];
|
||||
// index1 = telemetry_id (sampling key)
|
||||
assert.deepEqual(point.indexes, ["11111111-2222-3333-4444-555555555555"]);
|
||||
// FIREHOSE_SCHEMA blob positions (append-only contract):
|
||||
assert.equal(point.blobs[0], "onboarding_step"); // blob1 = event
|
||||
assert.equal(point.blobs[7], "auth_failed"); // blob8 = step
|
||||
assert.equal(point.blobs[8], "testprov"); // blob9 = auth_provider
|
||||
assert.equal(point.blobs[10], "callback_timeout"); // blob11 = auth_failure_reason
|
||||
assert.equal(point.blobs.length, 20);
|
||||
assert.equal(point.doubles.length, 20);
|
||||
|
||||
assert.ok(db.executed.some(({ sql }) => /INSERT OR IGNORE INTO events/.test(sql)));
|
||||
});
|
||||
|
||||
test("discovery event is validated, firehosed, and persisted to details", async () => {
|
||||
const db = makeDb();
|
||||
const discoveryFirehose = makeFirehose();
|
||||
const response = await worker.fetch(
|
||||
postRequest(makeDiscoveryBody()),
|
||||
{ DB: db, FIREHOSE_DISCOVERY: discoveryFirehose },
|
||||
makeCtx(),
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.firehose, true);
|
||||
assert.equal(discoveryFirehose.points.length, 1);
|
||||
const point = discoveryFirehose.points[0];
|
||||
assert.equal(point.blobs[7], "11111111-2222-4333-8444-555555555555");
|
||||
assert.equal(point.blobs[8], "browse");
|
||||
assert.equal(point.blobs[9], "payments");
|
||||
assert.equal(point.blobs[11], "success");
|
||||
assert.equal(point.doubles[3], 200);
|
||||
assert.equal(point.doubles[4], 125);
|
||||
assert.equal(point.doubles[7], 1);
|
||||
|
||||
const detailInsert = db.executed.find(({ sql }) => /INSERT OR IGNORE INTO discovery_details/.test(sql));
|
||||
assert.ok(detailInsert);
|
||||
assert.ok(detailInsert.values.includes("11111111-2222-4333-8444-555555555555"));
|
||||
assert.ok(detailInsert.values.includes("payments"));
|
||||
assert.ok(!detailInsert.values.some((value) => String(value).includes("virtual card")));
|
||||
});
|
||||
|
||||
test("discovery event rejects unknown failure classifications", async () => {
|
||||
const response = await worker.fetch(
|
||||
postRequest(makeDiscoveryBody({ outcome: "failure", failure_reason: "raw secret error" })),
|
||||
{ DB: makeDb(), FIREHOSE_DISCOVERY: makeFirehose() },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(response.status, 400);
|
||||
assert.match((await response.json()).error, /failure_reason/);
|
||||
});
|
||||
|
||||
test("D1 failure with firehose success degrades to durable:false instead of 500", async () => {
|
||||
const db = makeDb({ failInserts: true });
|
||||
const firehose = makeFirehose();
|
||||
const ctx = makeCtx();
|
||||
|
||||
const response = await worker.fetch(postRequest(makeBody()), { DB: db, FIREHOSE: firehose }, ctx);
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.durable, false);
|
||||
assert.equal(json.firehose, true);
|
||||
assert.equal(firehose.points.length, 1);
|
||||
});
|
||||
|
||||
test("SQLITE_FULL-class insert failure schedules an emergency prune", async () => {
|
||||
const db = makeDb({ failInserts: true, failureMessage: "SQLITE_FULL: database or disk is full" });
|
||||
const firehose = makeFirehose();
|
||||
const ctx = makeCtx();
|
||||
|
||||
await worker.fetch(postRequest(makeBody()), { DB: db, FIREHOSE: firehose }, ctx);
|
||||
// The prune is scheduled via ctx.waitUntil; drain it and check DELETEs ran.
|
||||
await Promise.all(ctx.waited);
|
||||
|
||||
assert.ok(
|
||||
db.executed.some(({ sql }) => /DELETE FROM events/.test(sql)),
|
||||
"emergency prune should issue DELETEs after a full-database failure",
|
||||
);
|
||||
});
|
||||
|
||||
test("D1 failure without firehose binding still returns 500", async () => {
|
||||
const db = makeDb({ failInserts: true, failureMessage: "some transient error" });
|
||||
const ctx = makeCtx();
|
||||
|
||||
const response = await worker.fetch(postRequest(makeBody()), { DB: db }, ctx);
|
||||
assert.equal(response.status, 500);
|
||||
});
|
||||
|
||||
test("missing firehose binding degrades gracefully", async () => {
|
||||
const db = makeDb();
|
||||
const ctx = makeCtx();
|
||||
|
||||
const response = await worker.fetch(postRequest(makeBody()), { DB: db }, ctx);
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.durable, true);
|
||||
assert.equal(json.firehose, false);
|
||||
});
|
||||
|
||||
test("health endpoint reports database size vs soft limit", async () => {
|
||||
const db = makeDb({ sizeAfter: 12345678 });
|
||||
const ctx = makeCtx();
|
||||
|
||||
const response = await worker.fetch(new Request(HEALTH_URL, { method: "GET" }), { DB: db }, ctx);
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.db_size_bytes, 12345678);
|
||||
assert.equal(typeof json.db_soft_limit_bytes, "number");
|
||||
assert.equal(json.over_soft_limit, false);
|
||||
});
|
||||
|
||||
test("unknown event type is rejected", async () => {
|
||||
const db = makeDb();
|
||||
const ctx = makeCtx();
|
||||
const response = await worker.fetch(
|
||||
postRequest(makeBody({ event: "mystery" })),
|
||||
{ DB: db },
|
||||
ctx,
|
||||
);
|
||||
assert.equal(response.status, 400);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Website analytics events (web_pageview / web_cta_click)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeWebBody(overrides = {}) {
|
||||
return {
|
||||
event: "web_pageview",
|
||||
visitor_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
path: "/pricing",
|
||||
referrer: "https://news.ycombinator.com/",
|
||||
utm_source: "hn",
|
||||
utm_medium: "social",
|
||||
utm_campaign: "launch",
|
||||
event_id: "web-event-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("web_pageview is normalized and stored in events + web_details", async () => {
|
||||
const db = makeDb();
|
||||
const ctx = makeCtx();
|
||||
|
||||
const response = await worker.fetch(postRequest(makeWebBody()), { DB: db }, ctx);
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.durable, true);
|
||||
|
||||
const eventsInsert = db.executed.find(({ sql }) => /INSERT OR IGNORE INTO events/.test(sql));
|
||||
assert.ok(eventsInsert, "events row inserted");
|
||||
// visitor_id doubles as the telemetry id; version/os/arch are defaulted.
|
||||
assert.ok(eventsInsert.values.includes("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
|
||||
assert.ok(eventsInsert.values.includes("web"));
|
||||
|
||||
const detailInsert = db.executed.find(({ sql }) => /INSERT OR IGNORE INTO web_details/.test(sql));
|
||||
assert.ok(detailInsert, "web_details row inserted");
|
||||
assert.ok(detailInsert.values.includes("/pricing"));
|
||||
assert.ok(detailInsert.values.includes("hn"));
|
||||
});
|
||||
|
||||
test("web_pageview without event_id mints one so web_details still lands", async () => {
|
||||
// The real beacon (jcode-website public/beacon.js) does not send event_id;
|
||||
// web_details joins on it, so the worker must mint one server-side.
|
||||
const db = makeDb();
|
||||
const ctx = makeCtx();
|
||||
|
||||
const body = makeWebBody();
|
||||
delete body.event_id;
|
||||
const response = await worker.fetch(postRequest(body), { DB: db }, ctx);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const detailInsert = db.executed.find(({ sql }) => /INSERT OR IGNORE INTO web_details/.test(sql));
|
||||
assert.ok(detailInsert, "web_details row inserted despite missing event_id");
|
||||
assert.ok(detailInsert.values.includes("/pricing"));
|
||||
});
|
||||
|
||||
test("web_pageview without visitor_id is rejected", async () => {
|
||||
const db = makeDb();
|
||||
const response = await worker.fetch(
|
||||
postRequest(makeWebBody({ visitor_id: undefined })),
|
||||
{ DB: db },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(response.status, 400);
|
||||
});
|
||||
|
||||
test("web_pageview without path is rejected", async () => {
|
||||
const db = makeDb();
|
||||
const response = await worker.fetch(
|
||||
postRequest(makeWebBody({ path: undefined })),
|
||||
{ DB: db },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(response.status, 400);
|
||||
});
|
||||
|
||||
test("web_cta_click requires cta", async () => {
|
||||
const db = makeDb();
|
||||
const missing = await worker.fetch(
|
||||
postRequest(makeWebBody({ event: "web_cta_click" })),
|
||||
{ DB: db },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(missing.status, 400);
|
||||
|
||||
const ok = await worker.fetch(
|
||||
postRequest(makeWebBody({ event: "web_cta_click", cta: "plus_early_access" })),
|
||||
{ DB: db },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(ok.status, 200);
|
||||
const detailInsert = db.executed.find(({ sql }) => /INSERT OR IGNORE INTO web_details/.test(sql));
|
||||
assert.ok(detailInsert.values.includes("plus_early_access"));
|
||||
});
|
||||
|
||||
test("web free-text fields are length-capped (size defense)", async () => {
|
||||
const db = makeDb();
|
||||
const response = await worker.fetch(
|
||||
postRequest(makeWebBody({ path: "/" + "x".repeat(5000), referrer: "r".repeat(5000) })),
|
||||
{ DB: db },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const detailInsert = db.executed.find(({ sql }) => /INSERT OR IGNORE INTO web_details/.test(sql));
|
||||
for (const value of detailInsert.values) {
|
||||
assert.ok(String(value).length <= 200, "web detail values capped at 200 chars");
|
||||
}
|
||||
});
|
||||
|
||||
test("web events are firehosed to FIREHOSE_WEB with visitor_id as index1", async () => {
|
||||
const db = makeDb();
|
||||
const firehose = makeFirehose();
|
||||
const webFirehose = makeFirehose();
|
||||
|
||||
const response = await worker.fetch(
|
||||
postRequest(makeWebBody({ event: "web_cta_click", cta: "install" })),
|
||||
{ DB: db, FIREHOSE: firehose, FIREHOSE_WEB: webFirehose },
|
||||
makeCtx(),
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(json.firehose, true);
|
||||
assert.equal(firehose.points.length, 0, "CLI firehose untouched by web events");
|
||||
assert.equal(webFirehose.points.length, 1);
|
||||
const point = webFirehose.points[0];
|
||||
assert.deepEqual(point.indexes, ["aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]);
|
||||
// FIREHOSE_WEB_SCHEMA blob positions (append-only contract):
|
||||
assert.equal(point.blobs[0], "web_cta_click"); // blob1 = event
|
||||
assert.equal(point.blobs[7], "/pricing"); // blob8 = path
|
||||
assert.equal(point.blobs[9], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); // blob10 = visitor_id
|
||||
assert.equal(point.blobs[13], "install"); // blob14 = cta
|
||||
});
|
||||
|
||||
test("web_vital validates, caps, stores, and appends firehose fields", async () => {
|
||||
const db = makeDb();
|
||||
const webFirehose = makeFirehose();
|
||||
const response = await worker.fetch(
|
||||
postRequest(makeWebBody({
|
||||
event: "web_vital",
|
||||
metric_name: "LCP",
|
||||
metric_value: 999_999,
|
||||
rating: "poor",
|
||||
message: "must not persist",
|
||||
url: "https://jcode.sh/private?token=secret",
|
||||
})),
|
||||
{ DB: db, FIREHOSE_WEB: webFirehose },
|
||||
makeCtx(),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const detailInsert = db.executed.find(({ sql }) => /INSERT OR IGNORE INTO web_details/.test(sql));
|
||||
assert.ok(detailInsert.sql.includes("metric_name"));
|
||||
assert.ok(detailInsert.sql.includes("metric_value"));
|
||||
assert.ok(detailInsert.sql.includes("rating"));
|
||||
assert.ok(detailInsert.values.includes("LCP"));
|
||||
assert.ok(detailInsert.values.includes(300_000));
|
||||
assert.ok(detailInsert.values.includes("poor"));
|
||||
assert.ok(!detailInsert.values.some((value) => String(value).includes("must not persist")));
|
||||
assert.ok(!detailInsert.values.some((value) => String(value).includes("token=secret")));
|
||||
|
||||
const point = webFirehose.points[0];
|
||||
assert.equal(point.blobs[17], "LCP"); // blob18 = metric_name
|
||||
assert.equal(point.blobs[18], "poor"); // blob19 = rating
|
||||
assert.equal(point.blobs[19], ""); // blob20 = error_kind
|
||||
assert.equal(point.doubles[1], 300_000); // double2 = metric_value
|
||||
});
|
||||
|
||||
test("web_vital accepts only standard finite nonnegative metrics and ratings", async () => {
|
||||
const invalidBodies = [
|
||||
{ metric_name: "FID", metric_value: 1, rating: "good" },
|
||||
{ metric_name: "CLS", metric_value: -1, rating: "poor" },
|
||||
{ metric_name: "CLS", metric_value: "0.1", rating: "good" },
|
||||
{ metric_name: "CLS", metric_value: null, rating: "good" },
|
||||
{ metric_name: "CLS", metric_value: 0.1, rating: "okay" },
|
||||
];
|
||||
for (const fields of invalidBodies) {
|
||||
const response = await worker.fetch(
|
||||
postRequest(makeWebBody({ event: "web_vital", ...fields })),
|
||||
{ DB: makeDb(), FIREHOSE_WEB: makeFirehose() },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(response.status, 400, JSON.stringify(fields));
|
||||
}
|
||||
|
||||
const clsDb = makeDb();
|
||||
const clsResponse = await worker.fetch(
|
||||
postRequest(makeWebBody({ event: "web_vital", metric_name: "CLS", metric_value: 99, rating: "poor" })),
|
||||
{ DB: clsDb },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(clsResponse.status, 200);
|
||||
const clsInsert = clsDb.executed.find(({ sql }) => /INSERT OR IGNORE INTO web_details/.test(sql));
|
||||
assert.ok(clsInsert.values.includes(10));
|
||||
});
|
||||
|
||||
test("web_error stores only an allowed coarse classification", async () => {
|
||||
for (const error_kind of ["script", "promise", "resource"]) {
|
||||
const db = makeDb();
|
||||
const webFirehose = makeFirehose();
|
||||
const response = await worker.fetch(
|
||||
postRequest(makeWebBody({
|
||||
event: "web_error",
|
||||
error_kind,
|
||||
error_message: "private failure detail",
|
||||
stack: "secret stack",
|
||||
filename: "https://cdn.example/private.js",
|
||||
})),
|
||||
{ DB: db, FIREHOSE_WEB: webFirehose },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const detailInsert = db.executed.find(({ sql }) => /INSERT OR IGNORE INTO web_details/.test(sql));
|
||||
assert.ok(detailInsert.values.includes(error_kind));
|
||||
assert.ok(!detailInsert.values.some((value) => /private|secret|cdn\.example|ycombinator/.test(String(value))));
|
||||
assert.equal(webFirehose.points[0].blobs[19], error_kind); // blob20
|
||||
}
|
||||
|
||||
const rejected = await worker.fetch(
|
||||
postRequest(makeWebBody({ event: "web_error", error_kind: "TypeError: secret" })),
|
||||
{ DB: makeDb() },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(rejected.status, 400);
|
||||
});
|
||||
|
||||
test("scheduled retention uses 30 days for web_vital and 90 days for web_error", async () => {
|
||||
const db = makeDb();
|
||||
const ctx = makeCtx();
|
||||
await worker.scheduled({}, { DB: db }, ctx);
|
||||
await Promise.all(ctx.waited);
|
||||
|
||||
const eventDeletes = db.executed.filter(({ sql }) => /DELETE FROM events WHERE id IN/.test(sql));
|
||||
assert.ok(eventDeletes.some(({ values }) => values[0] === "web_vital" && values[1] === "-30 days"));
|
||||
assert.ok(eventDeletes.some(({ values }) => values[0] === "web_error" && values[1] === "-90 days"));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token subscription plan events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeSubscriptionBody(overrides = {}) {
|
||||
return makeBody({
|
||||
event: "subscription_activated",
|
||||
step: undefined,
|
||||
auth_provider: undefined,
|
||||
auth_method: undefined,
|
||||
auth_failure_reason: undefined,
|
||||
account_id: "acct_123",
|
||||
tier: "plus",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test("subscription events require account_id", async () => {
|
||||
const db = makeDb();
|
||||
for (const event of [
|
||||
"subscription_login",
|
||||
"subscription_activated",
|
||||
"subscription_budget_exhausted",
|
||||
"subscription_router_error",
|
||||
"account_linked",
|
||||
]) {
|
||||
const response = await worker.fetch(
|
||||
postRequest(makeSubscriptionBody({ event, account_id: undefined })),
|
||||
{ DB: db },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(response.status, 400, `${event} without account_id rejected`);
|
||||
}
|
||||
});
|
||||
|
||||
test("subscription_activated stores account_id and tier", async () => {
|
||||
const db = makeDb();
|
||||
const response = await worker.fetch(
|
||||
postRequest(makeSubscriptionBody()),
|
||||
{ DB: db },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const insert = db.executed.find(({ sql }) => /INSERT OR IGNORE INTO events/.test(sql));
|
||||
assert.ok(insert.sql.includes("account_id"));
|
||||
assert.ok(insert.sql.includes("tier"));
|
||||
assert.ok(insert.values.includes("acct_123"));
|
||||
assert.ok(insert.values.includes("plus"));
|
||||
});
|
||||
|
||||
test("subscription model is stored in the generic model_start column", async () => {
|
||||
const db = makeDb();
|
||||
const response = await worker.fetch(
|
||||
postRequest(makeSubscriptionBody({ event: "subscription_router_error", model: "gpt-5.5" })),
|
||||
{ DB: db },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const insert = db.executed.find(({ sql }) => /INSERT OR IGNORE INTO events/.test(sql));
|
||||
assert.ok(insert.sql.includes("model_start"));
|
||||
assert.ok(insert.values.includes("gpt-5.5"));
|
||||
});
|
||||
|
||||
test("account_linked joins telemetry_id and account_id", async () => {
|
||||
const db = makeDb();
|
||||
const response = await worker.fetch(
|
||||
postRequest(makeSubscriptionBody({ event: "account_linked", tier: undefined })),
|
||||
{ DB: db },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const insert = db.executed.find(({ sql }) => /INSERT OR IGNORE INTO events/.test(sql));
|
||||
assert.ok(insert.values.includes("11111111-2222-3333-4444-555555555555"));
|
||||
assert.ok(insert.values.includes("acct_123"));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CORS for the website beacon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("OPTIONS preflight from jcode.sh echoes the origin", async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request(EVENT_URL, {
|
||||
method: "OPTIONS",
|
||||
headers: { Origin: "https://jcode.sh" },
|
||||
}),
|
||||
{ DB: makeDb() },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Origin"), "https://jcode.sh");
|
||||
assert.equal(response.headers.get("Vary"), "Origin");
|
||||
assert.ok(/POST/.test(response.headers.get("Access-Control-Allow-Methods")));
|
||||
});
|
||||
|
||||
test("OPTIONS preflight from the production website echoes the origin", async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request(EVENT_URL, {
|
||||
method: "OPTIONS",
|
||||
headers: { Origin: "https://solosystems.dev" },
|
||||
}),
|
||||
{ DB: makeDb(), ALLOWED_ORIGIN: "https://fallback.example" },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Origin"), "https://solosystems.dev");
|
||||
assert.equal(response.headers.get("Vary"), "Origin");
|
||||
});
|
||||
|
||||
test("OPTIONS preflight from pages.dev preview echoes the origin", async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request(EVENT_URL, {
|
||||
method: "OPTIONS",
|
||||
headers: { Origin: "https://solosystems.pages.dev" },
|
||||
}),
|
||||
{ DB: makeDb() },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Origin"), "https://solosystems.pages.dev");
|
||||
});
|
||||
|
||||
test("other origins fall back to ALLOWED_ORIGIN default", async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request(EVENT_URL, {
|
||||
method: "OPTIONS",
|
||||
headers: { Origin: "https://evil.example" },
|
||||
}),
|
||||
{ DB: makeDb() },
|
||||
makeCtx(),
|
||||
);
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Origin"), "*");
|
||||
});
|
||||
|
||||
test("POST responses from the beacon origin carry CORS headers", async () => {
|
||||
const db = makeDb();
|
||||
const request = new Request(EVENT_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Origin: "https://jcode.sh",
|
||||
},
|
||||
body: JSON.stringify(makeWebBody()),
|
||||
});
|
||||
const response = await worker.fetch(request, { DB: db }, makeCtx());
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Origin"), "https://jcode.sh");
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Canonical "total users" definitions for jcode telemetry.
|
||||
-- Usage:
|
||||
-- wrangler d1 execute jcode-telemetry --remote --file=users.sql
|
||||
--
|
||||
-- Headline number: total_users. A "user" is a distinct, non-CI telemetry_id that
|
||||
-- ever either installed jcode or did meaningful work in it. We exclude CI traffic
|
||||
-- (ephemeral runners mint a fresh id per job) and exclude empty open/close
|
||||
-- sessions that never did anything. Raw, less-filtered tiers are reported
|
||||
-- alongside it so no signal is hidden.
|
||||
--
|
||||
-- Implementation note: high-volume raw events (turn_end, session_start,
|
||||
-- onboarding_step) are pruned on a retention schedule to stay under the D1
|
||||
-- 500 MB cap, so meaningful-activity membership comes from the durable
|
||||
-- daily_active_users rollup (maintained at insert time and backfilled across
|
||||
-- full history by migration 0014). install events are never pruned and anchor
|
||||
-- the install tier.
|
||||
--
|
||||
-- Caveats (see README "Accuracy notes"): telemetry_id is per-machine, so one
|
||||
-- person on N machines counts as N; opt-outs and network-blocked clients are
|
||||
-- never counted; CI rows created before the is_ci column existed default to 0
|
||||
-- and may slip in.
|
||||
|
||||
WITH install_ids AS (
|
||||
SELECT DISTINCT telemetry_id
|
||||
FROM events INDEXED BY idx_events_event_telemetry_created
|
||||
WHERE event = 'install' AND is_ci = 0
|
||||
), meaningful_ids AS (
|
||||
SELECT DISTINCT telemetry_id
|
||||
FROM daily_active_users
|
||||
WHERE meaningful_active > 0 AND last_is_ci = 0
|
||||
), reached_ids AS (
|
||||
SELECT DISTINCT telemetry_id FROM daily_active_users WHERE last_is_ci = 0
|
||||
UNION
|
||||
SELECT DISTINCT telemetry_id FROM events WHERE is_ci = 0
|
||||
), all_ids AS (
|
||||
SELECT DISTINCT telemetry_id FROM daily_active_users
|
||||
UNION
|
||||
SELECT DISTINCT telemetry_id FROM events
|
||||
), ci_ids AS (
|
||||
SELECT DISTINCT telemetry_id FROM daily_active_users WHERE last_is_ci = 1
|
||||
UNION
|
||||
SELECT DISTINCT telemetry_id FROM events WHERE is_ci = 1
|
||||
)
|
||||
SELECT
|
||||
-- HEADLINE: real people who installed or meaningfully used jcode.
|
||||
(SELECT COUNT(*) FROM (
|
||||
SELECT telemetry_id FROM install_ids
|
||||
UNION
|
||||
SELECT telemetry_id FROM meaningful_ids
|
||||
)) AS total_users,
|
||||
|
||||
-- Core users: did meaningful work (excludes install-only, never-used ids).
|
||||
(SELECT COUNT(*) FROM meaningful_ids) AS core_users,
|
||||
|
||||
-- Reach: every distinct non-CI id that ever launched jcode (incl. empty
|
||||
-- open/close sessions). Upper bound on "people who ran it at least once".
|
||||
(SELECT COUNT(*) FROM reached_ids) AS reached_users,
|
||||
|
||||
-- Installs only (non-CI), for comparison with total_users.
|
||||
(SELECT COUNT(*) FROM install_ids) AS installed_users,
|
||||
|
||||
-- Unfiltered grand total (includes CI + dev). Never use as the headline;
|
||||
-- kept for transparency and for sizing CI noise.
|
||||
(SELECT COUNT(*) FROM all_ids) AS all_ids_including_ci,
|
||||
|
||||
-- CI-only ids, so the gap between all_ids and total_users is explainable.
|
||||
(SELECT COUNT(*) FROM ci_ids) AS ci_ids;
|
||||
@@ -0,0 +1,55 @@
|
||||
name = "jcode-telemetry"
|
||||
main = "src/worker.js"
|
||||
compatibility_date = "2025-01-01"
|
||||
|
||||
# Custom domain for the website beacon (jcode-website public/beacon.js posts
|
||||
# here). Managed as a Workers custom domain, so Cloudflare creates the DNS
|
||||
# record and certificate automatically on deploy.
|
||||
routes = [
|
||||
{ pattern = "telemetry.solosystems.dev", custom_domain = true }
|
||||
]
|
||||
|
||||
# Nightly retention prune (see RETENTION_DAYS in src/worker.js). D1 hard-caps
|
||||
# databases at 500 MB; without pruning, inserts eventually fail with 500s and
|
||||
# telemetry silently stops being recorded.
|
||||
[triggers]
|
||||
crons = ["17 4 * * *"]
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "jcode-telemetry"
|
||||
database_id = "abaa524c-3e90-4ba9-a569-027e78a083c6"
|
||||
|
||||
# High-volume firehose: every event is also written to Workers Analytics
|
||||
# Engine (time-series store, ~90-day retention, no database size cap). This is
|
||||
# the primary store for raw turn_end / session_start / onboarding_step
|
||||
# analysis; D1 keeps only a short raw tail for those events plus the durable
|
||||
# identity/lifecycle rows and the daily_active_users rollup. The firehose
|
||||
# write happens before the D1 insert, so telemetry keeps recording even if D1
|
||||
# hits its size cap. See FIREHOSE_SCHEMA in src/worker.js for column mapping.
|
||||
#
|
||||
# ACTION REQUIRED: Analytics Engine needs a one-time enable in the dashboard
|
||||
# (https://dash.cloudflare.com/9d028e2eacd63c11cbc7df1f9c7a43e4/workers/analytics-engine).
|
||||
# Until then this binding must stay commented out or `wrangler deploy` fails
|
||||
# with code 10089. The worker code degrades gracefully without the binding
|
||||
# (responses report firehose:false). After enabling, uncomment and redeploy.
|
||||
[[analytics_engine_datasets]]
|
||||
binding = "FIREHOSE"
|
||||
dataset = "jcode_telemetry_firehose"
|
||||
|
||||
# Web/subscription firehose (FIREHOSE_WEB_SCHEMA in src/worker.js). The main
|
||||
# FIREHOSE_SCHEMA is at Analytics Engine's 20-blob/20-double capacity, so
|
||||
# website + subscription events get their own dataset. Same one-time enable
|
||||
# caveat as above; the worker degrades gracefully without the binding.
|
||||
[[analytics_engine_datasets]]
|
||||
binding = "FIREHOSE_WEB"
|
||||
dataset = "jcode_web_firehose"
|
||||
|
||||
# Privacy-safe sponsored-discovery funnel and reliability telemetry. A separate
|
||||
# dataset avoids repurposing the full main firehose schema.
|
||||
[[analytics_engine_datasets]]
|
||||
binding = "FIREHOSE_DISCOVERY"
|
||||
dataset = "jcode_discovery_firehose"
|
||||
|
||||
[vars]
|
||||
ALLOWED_ORIGIN = "*"
|
||||
Reference in New Issue
Block a user