openvibers/openvibe.report README.md

README.md

main 21.3 KB · 308 lines · markdown

1# OpenVibe.Report
2
3> Every number on OpenVibe, honest.
4
5**Live site:** https://openvibe.report · **API docs:** https://openvibe.services · **For AI:** https://openvibe.report/llms.txt
6**Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test`
7
8**Status:** v0.1.0 service, now with its own analytics database and a shadow of Network's numbers (REP-2, plan
92026-10-28 §4.8 and §5.4). The pages still read where they read today; `REPORT_SOURCE=report` switches the numbers
10when the cutover is approved.
11**Domain:** `openvibe.report` · **Port:** 5080 · **Service id:** `report` · **Env prefix:** `OV_` / `REPORT_`
12**License:** AGPL-3.0 (same as every OpenVibe service).
13
14The Report room of the OpenVibe network: the open numbers every site publishes, The Rundown of what streamers talked
15about, and each stream's after-show report.
16
17## Purpose
18
19OpenVibe.Report is the analytics authority in shadow:
20
21- It **ingests** the analytics OpenVibe.Network records — `live.stream.ended` for creator analytics and the shared
22 navbar's anonymous frame beacon — and Network's open-stats answer (`GET /api/v1/stats?days=7|30|90`) into its own
23 PostgreSQL tables (migrations/).
24- It **computes** the same numbers Network answers, site-wide, per site and per channel, by UTC day.
25- It **quotes money from OpenVibe.Billing** through Billing's read API; it never derives a price, fee or amount.
26- It **shadows the AI Exchange** behind `REPORT_AI_EXCHANGE` (off): Billing's `billing.credit.settled@1`
27 settlements are summed by UTC day, resolved exact model id and provider access. The raw settlement is not stored,
28 and Report never sets a rate, a price or a balance.
29- It **shadows**: a job compares the same window from Network and from Report's tables and records every difference
30 in `report_shadow_diffs`.
31
32The pages and `GET /api/v1/stats` answer from `REPORT_SOURCE`, which defaults to `network`: today's behaviour. Setting
33`REPORT_SOURCE=report` makes them answer from Report's tables. The Rundown and after-show reports still come from
34OpenVibe.Live in both modes.
35
36| Source | Read | Used for |
37|---|---|---|
38| **OpenVibe.Network** | `GET /api/v1/stats?days=7\|30\|90` | `/`, `/stats`, `GET /api/v1/stats` while `REPORT_SOURCE=network`; also the ingest source |
39| **Report's tables** | `report_metric_days`, `report_metric_windows`, `report_streams` | the same routes while `REPORT_SOURCE=report` |
40| **OpenVibe.Live** — The Rundown | `GET /api/rundown/`, `/api/rundown/topics`, `/api/rundown/t/:slug`, `/api/rundown/e/:streamId`, `/api/rundown/s/:username` | `/rundown`, `/rundown/t/:slug`, `/rundown/e/:streamId`, `/rundown/s/:username` |
41| **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` |
42| **OpenVibe.Bench** | `GET /api/v1/kinds`, `/api/v1/boards/:kind?type=elo\|pareto\|cost`, `/api/v1/benchmarks`, `/api/v1/rankings`, `/api/v1/runs`, `/api/v1/overview`, `/api/v1/models/:model`, `/api/v1/models/:model/summary` | `/ai`, `/ai/:kind`, `/ai/<benchmark>`, `/ai/models/:model`, `/ai/compare`, `/ai/method`, `/ai/feed.xml`, `GET /api/v1/ai/results` |
43| **OpenVibe.AI** | `GET /api/v1/exchange/catalog` | `GET /api/v1/ai/leaderboard`, `/api/v1/ai/models/:id`, `/api/v1/ai/providers`, `/api/v1/ai/compare?models=`, `/api/v1/ai/picks` |
44| **OpenVibe.Billing** | `GET /api/v1/stats`, `GET /api/v1/transactions?subject=…` | the revenue read for a channel; network money counts |
45| **OpenVibe.Billing** — settlements, through Events | `billing.credit.settled@1` on `POST /internal/events` (only with `REPORT_AI_EXCHANGE=1`) | the AI Exchange aggregates and the staff page/API |
46
47A source that does not answer is null, after one short log line; the page (or API route) becomes a 404. The service
48never invents a zero and never throws a source error at the reader.
49
50## What Report owns now
51
52- **The analytics tables** in `migrations/0001_initial.sql`: `report_inbox` (event ids already handled),
53 `report_events`, `report_streams`, `report_frame_days`, `report_metric_days`, `report_metric_windows`,
54 `report_metric_json`, `report_dimensions`, `report_shadow_runs` and `report_shadow_diffs`.
55- **The AI meter** in `migrations/0002_ai_usage.sql` and `0003_ai_usage_surface.sql`: `report_ai_usage` (raw calls,
56 kept 90 days) and `report_ai_usage_days`; the consumer is `server/analytics/ai-usage.js`.
57- **The AI Exchange aggregates** in `migrations/0005_ai_exchange.sql`: `report_ai_exchange_days` (day, model,
58 provider access, requests, sandbox requests, the four token counts, exact nano-USD retail/shortfall/supply/margin),
59 `report_ai_exchange_seen` (settlement ids already counted) and `report_ai_exchange_rejected` (invalid payloads).
60 The consumer is `server/analytics/ai-exchange.js`.
61- **The computation** in `server/analytics/`: `ingest.js` (idempotent writes), `query.js` (Network's stats shape and
62 a channel's creator analytics), `shadow.js` (the comparison), `ai-exchange.js` (the Exchange aggregates).
63- **The jobs** in `server/jobs/analytics.js`: ingest every five minutes (7, 30 and 90-day windows) and shadow every
64 fifteen minutes (7 days by default).
65
66## Does not own
67
68- **Money** — OpenVibe.Billing. Report reads it with `billing.stats.read` and `billing.balance.read`; it holds no
69 ledger and computes no price.
70- **The Rundown and the after-show reports** — OpenVibe.Live. Report quotes them, including their counts and text.
71- **The benchmark results** — OpenVibe.Bench. Report renders its rows and never derives a score, cost or time; Bench stayed the authority when its public pages moved here (2026-11-08) and its old HTML pages answer 301 to `/ai` counterparts.
72- **Identity, sessions and writes** — Report has no sign-in, no cookies and no public write route. `POST
73 /internal/events` is loopback-only and HMAC-verified.
74- **The Shared Frame** — `openvibe-shared` (navbar, footer, theme-loader, static files), served from this site's own
75 pinned copy at `/shared/*`.
76
77## Pages
78
79All server-rendered in `server/views/index.js`; every dynamic value goes through `esc()`.
80
81| Page | What it shows |
82|---|---|
83| `/` | the 7-day open numbers, the Rundown's counts and lead, and the trending topics |
84| `/stats?days=7\|30\|90` | the network totals and a first look at every site (anything else is 30) |
85| `/me?days=7\|30\|90` | **signed in only, private, noindex**: your sites and activity (Network's history), what you made, your AI and Vibes, with a switch per section to publish it (the owner, 2026-11-09) |
86| `/u/:username` | a person's published stats: only the sections they turned on, with Person JSON-LD; noindex and no numbers until they publish |
87| `/rundown` | on air, the lead, the latest streams and every topic; canonical is OpenVibe.Live's `/rundown` |
88| `/rundown/t/:slug` | one topic, its takes and related topics; canonical is Live's `/rundown/t/:slug` |
89| `/rundown/e/:streamId` | one stream as an episode, with its segments; canonical is Live's `/rundown/e/:streamId` |
90| `/rundown/s/:username` | one streamer's episodes and topics; canonical is Live's `/rundown/:username` |
91| `/after-show/:streamId` | one after-show report |
92| `/c/:username` | one channel's latest after-show reports |
93| `/ai` | the AI overview: every published benchmark with its leader, runs and last run; every task kind with its leader, quality, cost and speed; a link to the method and compare pages |
94| `/ai/:kind` | one task kind: Elo rank, quality against cost, cost per solved, the verified runs on its benchmarks, and the latest runs |
95| `/ai/<benchmark>` | one public Bench benchmark: verified rankings with score, cost, speed and last run, a score-against-cost SVG, and the latest runs |
96| `/ai/models/:model` | a model's runs over time, its best and worst tasks, cost, per-benchmark record and recent runs |
97| `/ai/compare` | two or three models on the same benchmarks, side by side |
98| `/ai/method` | how a score is made, how runs are judged, verified vs submitted, and what the counts mean |
99| `/ai/feed.xml` | new public runs as RSS; the same rows are `GET /api/v1/ai/results` |
100| `/ai/exchange` | **staff only**, with `REPORT_AI_EXCHANGE=1`: Billing's settlements by day, model and access, a daily chart and the exact amounts. Needs a signed-in person whose staff role holds `staff.site.view` |
101
102A bad path parameter (slug, stream id, username) or a source that answered nothing renders `views.notFound()` with
103status 404. Pages are `Cache-Control: public, max-age=120`.
104AI pages use a 60-second Bench read cache and serve its last good copy with the read time during an outage. Their
105HTML is `no-store` so the outage notice stays current.
106
107## API
108
109All under `/api/v1`, CORS `*`, `Cache-Control: public, max-age=120` (the popularity pair: 300), 120 requests a minute per address:
110
111```
112GET /api/v1/stats?days=7|30|90 REPORT_SOURCE=network|report
113GET /api/v1/popular?limit=5 the front door's ranked sites and tools (public, aggregate)
114GET /api/v1/popular/co-usage opt-in site pairs for "Try next", small groups omitted
115GET /api/v1/rundown
116GET /api/v1/rundown/topics
117GET /api/v1/rundown/t/:slug
118GET /api/v1/after-show/:streamId
119GET /api/v1/c/:username
120GET /api/v1/ai/results the new AI runs, newest first
121GET /api/v1/me/stats?days=7|30|90 the signed-in person's own numbers (private, no-store)
122PUT /api/v1/me/stats/published the five per-section switches (same-origin, JSON)
123GET /api/v1/u/:name/stats only the sections that person published (404 when none)
124```
125
126A bad parameter or a missing source answer is a 404 `application/problem+json` body built with
127`openvibe-contracts` `http.sendProblem`. The API is read-only except for the person's own publish switches; other
128methods get 405. `GET /api/v1/me/stats` and `PUT /api/v1/me/stats/published` answer the `report.me-stats@1` document
129(the switch request is `report.me-stats-publish@1`); the public route copies only the published sections from the
130person's last snapshot, so a section a source did not answer for is absent, never a zero.
131
132`/api/v1/popular` ranks Report's own daily and window numbers: opens today (UTC, still counting) and over seven days,
133the seven before that for growth, and the distinct signed-in people Network's cross-site history counts. A tool is a
134destination beside a site. The answer is public and aggregate only, held five minutes. `/api/v1/popular/co-usage`
135reads only people who switched their `sites` section public (`report_me_stats.published`); it returns pair counts, never
136a person, username or subject, and a pair below three people is left out.
137
138The AI Exchange JSON read is the staff exception (with `REPORT_AI_EXCHANGE=1`): a Network service token with the
139same `report.ai_usage.read` capability the AI meter uses, never cached, no person or project in the answer. The
140HTML page is gated by the signed-in person's staff role (`staff.site.view`) instead, like the other staff pages.
141
142```
143GET /api/v1/ai/exchange?from=YYYY-MM-DD&to=YYYY-MM-DD the Exchange aggregates, staff only
144```
145
146The AI performance rows (`report.ai-performance@1`, plan L40/L41) answer the speed, reliability, tokens and money of
147the calls Report meters. Model, provider and operation are public and show only a group with at least 20 calls; system,
148task, place and harness need a signed-in person with `staff.site.view` or a service token with
149`report.ai_usage.read`, and are never cached. The percentiles come from log-scale histograms and are the upper bound
150of the bucket a call lands in; a task's cost and time are the sums of the calls that share its `task_id`.
151
152```
153GET /api/v1/ai/performance?group=model|provider|operation&days=7|30|90 public, 20-call floor
154GET /api/v1/ai/performance?group=system|task|place|harness&days=7|30|90 staff, filters allowed
155```
156
157### Internal (loopback only)
158
159```
160POST /internal/events OpenVibe.Events delivery (v2 HMAC, REPORT_EVENTS_SECRET)
161GET /internal/shadow?days=7&limit=5 the latest shadow runs and their differences
162POST /internal/shadow?days=7 run a comparison now
163GET /internal/metrics?scope=&id=&days= the flat report.metric@1 rows
164GET /internal/channels/:channel/analytics a channel's creator analytics plus Billing revenue
165```
166
167nginx refuses `/internal/` from outside; the app also checks the socket is loopback. The flat rows are proposed as
168`report.metric@1` in [docs/report.metric.v1.json](docs/report.metric.v1.json) for Contracts to own.
169
170## Ingest and idempotency
171
172- `live.stream.ended` → one `report_streams` row (same fields as Network's `creator_streams`) and the channel/day
173 counts. A redelivered event id is a no-op; a corrected copy with a new event id replaces the stream and does not
174 count it twice.
175- `network.frame.hit` / `network.frame.day` → `report_frame_days` (day, host, hits).
176- `GET /api/v1/stats` → `report_metric_days`, `report_metric_windows`, `report_metric_json` and site dimensions,
177 keyed by the answer's content hash. Polling the same numbers again is a no-op.
178- Every delivery is claimed in `report_inbox` (openvibe-sdk/events `createPgInbox`) in the same transaction as its
179 writes.
180- `billing.credit.settled` (with `REPORT_AI_EXCHANGE=1`) → `report_ai_exchange_days`, keyed by day, model and
181 provider access, and `report_ai_exchange_seen` by settlement id. The payload is validated against
182 `billing.credit.settled@1`; an invalid one is counted in `report_ai_exchange_rejected` and never aggregated.
183 Replaying the same settlement under a new event id counts once. A sandbox settlement only increments
184 `sandbox_requests`. The raw settlement is never stored. With the flag off, the delivery is ignored before anything
185 is written.
186- Billing's other money still comes from Billing's read API, never from an event.
187
188## Configuration
189
190Every value comes from the environment (production: `/etc/openvibe/report.env`). See [.env.example](.env.example).
191
192| Name | Default | Meaning |
193|---|---|---|
194| `NODE_ENV` | `development` | production switches the base URL and is required for a real deploy |
195| `PORT` | `5080` | the loopback port nginx proxies to |
196| `HOST` | `127.0.0.1` | listen address |
197| `BASE_URL` | `https://openvibe.report` in production, otherwise `http://localhost:5080` | canonical links, sitemap and llms.txt |
198| `TRUST_PROXY` | `2` | Express trust proxy (the app sets no cookies, but the per-address limit uses the client address) |
199| `DATABASE_URL` | unset in development | Report's PostgreSQL through PgBouncer; without it development uses PGlite |
200| `DATABASE_DIRECT_URL` | unset | the owner connection migrations run on |
201| `REPORT_PGLITE_DIR` | `./data/pglite` | the embedded development database |
202| `REPORT_SOURCE` | `network` | `network` keeps today's behaviour; `report` answers from Report's tables |
203| `OV_NETWORK_INTERNAL_URL` | `http://127.0.0.1:4000` | Network's loopback origin (its manifest's `internalOrigin`) |
204| `OV_NETWORK_URL` | `https://openvibe.network` | the OAuth token endpoint's origin |
205| `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Live's loopback origin |
206| `OV_BENCH_INTERNAL_URL` | `https://bench.openvibe.services` | Bench's public read origin, optionally an internal origin |
207| `OV_AI_INTERNAL_URL` | `https://api.openvibe.exchange` | AI's public Exchange catalogue and measured provider offers (`/api/v1/exchange/catalog`; the ai.openvibe.services vhost does not pass it) |
208| `OV_BILLING_INTERNAL_URL` | `http://127.0.0.1:4600` | Billing's loopback origin |
209| `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` | `report` / unset | Report's client-credentials token for `openvibe.billing` |
210| `REPORT_BILLING_TOKEN` | unset | an explicit Billing bearer token for an operator run |
211| `REPORT_EVENTS_SECRET` | unset | the HMAC secret for `POST /internal/events` (32+ chars, comma list for rotation) |
212| `REPORT_AI_EXCHANGE` | `0` | `1` consumes Billing's `billing.credit.settled` events into the AI Exchange aggregates and opens `/ai/exchange` and `GET /api/v1/ai/exchange`; off consumes nothing |
213| `REPORT_JOBS` | `1` | `0` turns the ingest and shadow jobs off |
214| `REPORT_JOBS_ON_BOOT` | `1` | run both jobs once shortly after boot |
215| `REPORT_INGEST_INTERVAL_MS` | `300000` | how often the ingest job runs |
216| `REPORT_SHADOW_INTERVAL_MS` | `900000` | how often the shadow job runs |
217| `REPORT_SHADOW_WINDOW_DAYS` | `7` | the shadow window (7, 30 or 90) |
218| `REPORT_API_RATE_LIMIT_PER_MIN` | `120` | per-address API requests a minute |
219| `REPORT_READY_TIMEOUT_MS` | `2500` | how long a readiness probe may take |
220| `RELEASE_COMMIT` | unset | `/release.json`'s release; otherwise read from git in the checkout |
221
222## Run and test
223
224```sh
225npm install
226npm start # http://127.0.0.1:5080; development uses the embedded PGlite database
227npm test # every test/*.test.js, in its own process
228```
229
230Most tests stub both sources and pass `store: null`, so they need no database. The analytics tests use
231`test/helpers/db.js` (`createTestDb` from openvibe-sdk/testing) with a migrated PGlite, or the PostgreSQL containers
232with `REPORT_TEST_STORE=pg`. They cover ingest idempotency, every metric against a fixture's expected numbers, the
233shadow job's difference and clean run, `GET /internal/shadow`, the source flag, the Billing revenue read with a stub,
234the AI Exchange aggregation/validation/replay/BigInt/sandbox/staff-gate/flag-off cases, and the existing
235pages/API/health/crawl behaviour.
236
237`node --check` is run over `server/`, `test/` and `public/` by CI (`.github/workflows/ci.yml`, the shared
238OpenVibe.Shared test workflow).
239
240## Cutover
241
2421. Run the ingest and shadow jobs long enough to cover a full 90-day window, and check
243 `GET /internal/shadow` has zero differences (or only explained ones).
2442. Create Report's first-party grants on Network: `billing.stats.read` and `billing.balance.read` for the `report`
245 service (and the Network read grants when Report starts pulling creator analytics directly).
2463. Set `REPORT_SOURCE=report` in `/etc/openvibe/report.env` and restart; keep the Network ingest running.
2474. Leave Network writing during the dual-write window. Live's `ANALYTICS_SOURCE` can then move to Report; Network's
248 `/stats` becomes a 301 to `openvibe.report/stats`.
2495. After the N-1 window, a Contracts migration removes Network's analytics tables. Rollback before that is
250 `REPORT_SOURCE=network` and a restart.
251
252## Deploy
253
254The reference files are in `deploy/`:
255
256- `deploy/systemd/openvibe-report.service` — `User=ubuntu`, `WorkingDirectory=/opt/openvibe.report`,
257 `EnvironmentFile=/etc/openvibe/report.env`, `PORT=5080`, `SIGTERM` with a 10 s drain.
258- `deploy/nginx/openvibe.report.conf` — HTTP → HTTPS, `www` → apex 301, Let's Encrypt certificates under
259 `/etc/letsencrypt/live/openvibe.report/`, proxy to `127.0.0.1:5080`, the same security headers as Help's vhost,
260 `/api/` GET-only with a coarse rate limit, and `/internal/` refused from outside.
261
262`/api/ready` reports `network` and `live` as optional checks: one or both down is `status: "degraded"` with HTTP 200.
263When `DATABASE_URL` is configured, the database check is included and is required only after `REPORT_SOURCE=report`.
264
265## Security
266
267Every source text is escaped before it reaches HTML. Path parameters are checked against fixed patterns before any
268request. Report's own sign-in serves `/you` and the staff page, which reads the signed-in person's staff role. Its
269database has public channel facts, the AI meter and aggregate AI Exchange numbers; a raw settlement (a person, a
270project, money) is never stored. `/internal/` is loopback-only, and `POST /internal/events` verifies the Events v2
271HMAC. See [SECURITY.md](SECURITY.md).
272
273<!-- versions:start -->
274- openvibe-contracts: v0.421.0
275- openvibe-sdk: v0.161.0
276- openvibe-shared: v4.241.0
277- openvibe-publishing: v1.7.0
278<!-- versions:end -->
279
280### AI market data and provenance
281
282The AI pages and `GET /api/v1/ai/leaderboard` and `/api/v1/ai/providers` read
283`https://api.openvibe.exchange/api/v1/exchange/market` alongside the routed catalog.
284Report follows every market cursor and caches the complete result for 120 seconds.
285An upstream failure retains the last complete market snapshot with `stale: true`;
286without a snapshot, Report falls back to the routed catalog.
287
288The market is public (`exchange.market@1`, cached 60 seconds). It accepts `limit`
289(1–500, default 100), opaque `cursor`, `maker`, `open_weights=true|false`, and
290`kind=chat|embedding|stt|image`. Responses contain `offers`, `total_models`,
291`total_offers`, `next_cursor`, `generated_at`, and the models.dev metadata snapshot's
292`source`, `checked_at`, and `stale` state. Each offer identifies `model`, `provider`,
293`provider_model`, `routed`, `provider_access`, and the preset's `uncensored` flag.
294Prices are USD per 1M tokens (`in`, `out`, `cached`), with `price_source` pointing
295to that provider's own page and `price_checked_at`. Unknown prices remain null.
296A latest observed price can differ from the price currently approved for routing.
297
298Model fields include maker, context, max output, open weights, licence, release
299date, knowledge cutoff and modalities. `field_sources` names the provider page or
300`https://models.dev/api.json` for each known field. Metadata refreshes daily on
301read; a failed refresh preserves the previous copy and marks it stale.
302
303Comparison rows include every market model, rated rows first, then unrated rows.
304Their first provider offer has the lowest known own-page output price, breaking
305ties by input price; input/output/cached prices always come from that same offer.
306Provider measurements join only routed offers. Other offers display “Not routed
307here”; missing measurements on routed offers display “Not measured yet”.
308