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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
.wrangler/
+333
View File
@@ -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.
+58
View File
@@ -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;
+140
View File
@@ -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;
+36
View File
@@ -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"
}
}
+324
View File
@@ -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
+672
View File
@@ -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");
});
+67
View File
@@ -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;
+55
View File
@@ -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 = "*"