@mauroandre/velojs 0.0.57 → 0.0.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -467,6 +467,7 @@ A page value that must **track server state over time** — a total another
467
467
  person's request changes, data a background scheduler mutates — declares the
468
468
  channels that keep it fresh. No extra component, no manual subscription: the
469
469
  module exports `channels` next to its `loader`.
470
+ The one-sentence model: the live loader keeps a page's state replica faithful to the server — the first payload arrives through the usual path (SSR, `__PAGE_DATA__`, the `?_data=1` refetch), and from then on every change is **addressed**, by the app's own rules, to the group of clients it concerns, **by duty or by interest**, each one receiving the state computed for its own scope.
470
471
 
471
472
  ```tsx
472
473
  // app/expenses/Expenses.tsx
@@ -768,6 +769,8 @@ partitions is internal information, and it never ships in a production build.
768
769
 
769
770
  ### The partition contract
770
771
 
772
+ **Addressed, not broadcast.** A partition is not a filter applied after a broadcast. The `emit` is addressed: the runtime resolves the partition key and writes only to the connections of that group — a client outside the group never hears about the change, not even a byte. Two reasons make a partition matter, and they compose: **by duty** (authorization — the data belongs to that team, that family, that project; others must not see it) and **by interest** (relevance — the tab watching project 9 has nothing to wake up for when project 7 changes). The same `scope` serves both; the app decides which, or both at once.
773
+
771
774
  On subscribe the `scope` receives `{ user, params }`. `user` is `c.get("user")`
772
775
  — the house key, the same one the stream/socket channel resolvers use (a
773
776
  middleware that materializes the principal elsewhere should set it in `"user"`
@@ -869,6 +872,89 @@ surface) without advancing the fake timers through the window.
869
872
 
870
873
  ---
871
874
 
875
+ ## Telemetry
876
+
877
+ The framework records its own bottlenecks — pages, `?_data=1` refetches,
878
+ actions, endpoints, stream connects, socket setups, channel connects — plus
879
+ every `emit()` gesture that runs outside the request cycle, as JSONL lines with
880
+ duration, status, response bytes and child spans. Collection is transparent to
881
+ the app code: no import, no wrapper, no change in a route module.
882
+
883
+ ### Activation: the destination is the trigger
884
+
885
+ There is no on/off flag. `VELO_TELEMETRY_FILE` and/or `VELO_TELEMETRY_SINK_URL`
886
+ present and non-empty turn the collection on; none of them — absent or empty —
887
+ leaves the whole thing inert (no file, no event, no cost) in any environment,
888
+ dev included. The framework never picks a path: there is no default file, and
889
+ the values expand `~` and `$VAR`/`${VAR}`.
890
+
891
+ | Variable | Default | Description |
892
+ |----------|---------|-------------|
893
+ | `VELO_TELEMETRY_FILE` | — | Destination file (append-only JSONL). Present and non-empty activates the file collection. Expands `~`/`$VAR`; no default path |
894
+ | `VELO_TELEMETRY_SINK_URL` | — | Destination sink. Present and non-empty activates the HTTP collection: one `POST` per event |
895
+ | `VELO_TELEMETRY_MAX_MB` | `10` | Rotation limit of the file, in MB. The only pruning mechanism |
896
+
897
+ ```bash
898
+ # .env — the operator's destination, in production or in dev
899
+ VELO_TELEMETRY_FILE=$DATA_DIR/telemetry.jsonl
900
+ VELO_TELEMETRY_MAX_MB=10
901
+ ```
902
+
903
+ ### The event
904
+
905
+ One JSON object per line. `route` is always the path **pattern** (`/:mes`),
906
+ never the materialized URL; `bytes` is the response size where there is a body
907
+ (pages, data, actions, endpoints) — time and weight travel together. Every line
908
+ carries the trace id and its own timestamp.
909
+
910
+ ```json
911
+ {
912
+ "trace": "m3k9x1-4-f8a2q7",
913
+ "ts": "2026-10-07T18:22:03.482Z",
914
+ "type": "page",
915
+ "route": "/:mes",
916
+ "module": "MesPage",
917
+ "status": "ok",
918
+ "duration": 45.2,
919
+ "bytes": 122880,
920
+ "spans": [
921
+ { "name": "loader:AppLayout", "start": 0.8, "duration": 3.1, "status": "ok" },
922
+ { "name": "loader:MesPage", "start": 1.2, "duration": 43.9, "status": "ok" },
923
+ { "name": "render", "start": 44.3, "duration": 3.5, "status": "ok" }
924
+ ]
925
+ }
926
+ ```
927
+
928
+ Types: `page`, `data`, `action`, `endpoint`, `stream-connect`, `socket-connect`,
929
+ `channel-connect`, `emit`, `delivery`, `close` and `boot`. The `emit` trace has
930
+ no request parent — in the invalidation mode each re-executed loader is a child
931
+ span. A long connection (channel, stream, socket) writes one light `delivery`
932
+ line per frame, with the cumulative ordinal and the frame's bytes, and a final
933
+ `close` line with the total and the lifetime.
934
+
935
+ ### File, boot and rotation
936
+
937
+ The first line of every active destination is the boot record: the app version,
938
+ the environment, the **effective** destination and the rotation limit in force.
939
+ The effective file carries an instance suffix — `telemetry.jsonl` becomes
940
+ `telemetry.8123-host.jsonl` — so replicas pointing the same path write distinct
941
+ files. The file rotates by size (`VELO_TELEMETRY_MAX_MB`, default 10):
942
+ `<effective>.<n>` keeps the past, the effective path keeps the newest lines.
943
+
944
+ ### The CLI
945
+
946
+ ```bash
947
+ velojs telemetry # resolves VELO_TELEMETRY_FILE (env or .env), instance files included
948
+ velojs telemetry --last 60m # the last hour of the file
949
+ velojs telemetry ./t.prod.jsonl
950
+ ```
951
+
952
+ Ranks the JSONL by route, action and channel — count, errors, p50/p95 and
953
+ volume in bytes — and prints the timeline. The full reference lives in
954
+ [site/docs/22-telemetry.md](site/docs/22-telemetry.md).
955
+
956
+ ---
957
+
872
958
  ## Actions
873
959
 
874
960
  Server-side functions callable from the client via RPC.
@@ -1686,6 +1772,9 @@ Callbacks queue until the server starts. If called after startup, executes immed
1686
1772
  | `HOST` | — | Bind interface, in dev and production. Overrides `defineConfig`'s `hostname`; in dev an explicit `--host`/`server.host` still wins |
1687
1773
  | `NODE_ENV` | — | Set automatically by `velojs start`. Enables static file serving |
1688
1774
  | `STATIC_BASE_URL` | `""` | CDN/bucket prefix for static assets |
1775
+ | `VELO_TELEMETRY_FILE` | — | Telemetry destination file — present and non-empty activates the native collection (see [Telemetry](#telemetry)). Expands `~`/`$VAR` |
1776
+ | `VELO_TELEMETRY_SINK_URL` | — | Telemetry sink — present and non-empty also POSTs every event |
1777
+ | `VELO_TELEMETRY_MAX_MB` | `10` | Telemetry file rotation limit, in MB |
1689
1778
 
1690
1779
  ---
1691
1780
 
@@ -2025,7 +2114,7 @@ velojs build
2025
2114
 
2026
2115
  Asset filenames include content hashes for long-term browser caching. The server build reads the Vite manifest to inject the correct filenames into `<Scripts>`.
2027
2116
 
2028
- In production, `velojs start` sets `NODE_ENV=production` automatically and serves static files from `dist/client/`. HTML responses are served with `Cache-Control: no-cache` so browsers always fetch the latest HTML (which references the current hashed assets).
2117
+ In production, `velojs start` sets `NODE_ENV=production` automatically and serves static files from `dist/client/`. Assets carry content hashes and are **public by design**, so the server answers an existing file **before the app's route table** — a page route, an action endpoint or a route middleware (an auth guard redirecting to `/login`, for instance) never sees an asset path. Only the framework's own infra middlewares (trailing-slash normalization, the logger) run ahead of the asset lookup. Paths that are not files fall through to the app routes as usual (SSR pages, actions, streams, sockets, endpoints, the catch-all 404). HTML responses are served with `Cache-Control: no-cache` so browsers always fetch the latest HTML (which references the current hashed assets).
2029
2118
 
2030
2119
  ### Static assets on CDN
2031
2120
 
@@ -2035,7 +2124,7 @@ Set `STATIC_BASE_URL` to serve static assets from a CDN or S3 bucket:
2035
2124
  STATIC_BASE_URL=https://cdn.example.com/assets node dist/server.js
2036
2125
  ```
2037
2126
 
2038
- The `<Scripts />` component and CSS `url()` references will use this prefix automatically.
2127
+ The `<Scripts />` component and CSS `url()` references will use this prefix automatically. When `STATIC_BASE_URL` starts with `http`, the server mounts no local static at all — every path is an app path, and the assets' public-by-design semantics come from the bucket/CDN.
2039
2128
 
2040
2129
  ---
2041
2130
 
package/agent/AGENTS.md CHANGED
@@ -108,6 +108,8 @@ import { emit } from "@mauroandre/velojs/server";
108
108
  await emit("teamExpenses", { user: { teamId: 7 } });
109
109
  ```
110
110
 
111
+ An `emit` is addressed to the partition's group — clients outside the group never hear about it (not a broadcast the client filters); partitions exist by duty (authorization) or by interest (relevance).
112
+
111
113
  Or, when the producer already holds the new value, it pushes it as a typed slice:
112
114
 
113
115
  ```ts
@@ -5,7 +5,7 @@ description: "Keep a loader value faithful to server state in real time: declare
5
5
 
6
6
  # Live loader
7
7
 
8
- A `loader` fetches a page's data once per request and re-fetches it on SPA navigation. That is everything a page needs when its data changes because of the user themselves — or not at all. A **value that must track server state over time** (a team total another person's request changes, data a background scheduler mutates) needs the loader to stay faithful. That is the live loader: the same `loader`, the same `useLoader()` read, plus a sibling `channels` export.
8
+ A `loader` fetches a page's data once per request and re-fetches it on SPA navigation. That is everything a page needs when its data changes because of the user themselves — or not at all. A **value that must track server state over time** (a team total another person's request changes, data a background scheduler mutates) needs the loader to stay faithful. That is the live loader: the same `loader`, the same `useLoader()` read, plus a sibling `channels` export. The one-sentence model: the live loader keeps a page's state replica faithful to the server — the first payload arrives through the usual path (SSR, `__PAGE_DATA__`, the `?_data=1` refetch), and from then on every change is **addressed**, by the app's own rules, to the group of clients it concerns, **by duty or by interest**, each one receiving the state computed for its own scope.
9
9
 
10
10
  ## The three declarations
11
11
 
@@ -284,6 +284,8 @@ On a page with no `channels` the fields exist, the record is empty and the aggre
284
284
 
285
285
  ## The partition contract
286
286
 
287
+ **Addressed, not broadcast.** A partition is not a filter applied after a broadcast. The `emit` is addressed: the runtime resolves the partition key and writes only to the connections of that group — a client outside the group never hears about the change, not even a byte. Two reasons make a partition matter, and they compose: **by duty** (authorization — the data belongs to that team, that family, that project; others must not see it) and **by interest** (relevance — the tab watching project 9 has nothing to wake up for when project 7 changes). The same `scope` serves both; the app decides which, or both at once.
288
+
287
289
  On subscribe the `scope` receives `{ user, params }`. `user` is whatever a middleware materialized under the house key `"user"` — the same key the stream/socket channel resolvers use; a middleware that materializes the principal somewhere else should set it in `"user"` too:
288
290
 
289
291
  ```ts
@@ -25,6 +25,12 @@ npx velojs start
25
25
 
26
26
  This automatically sets `NODE_ENV=production` and starts the server. In production mode, VeloJS serves static files from `dist/client/` and handles SSR for all page routes.
27
27
 
28
+ ### Assets are served before the app routes
29
+
30
+ `dist/client` assets are **public by design**: their names carry content hashes and their URLs are fetched by the browser with no session and no route logic attached. The server answers an existing file from `dist/client` **before the app's route table** — so a page route, an action endpoint or a route middleware (an auth guard redirecting to `/login`, for instance) never sees an asset path. Only the framework's own infra middlewares (trailing-slash normalization and the logger) run ahead of the asset lookup.
31
+
32
+ Paths that are not files keep the normal flow: SSR pages, actions, streams, sockets, endpoints and the app's catch-all 404. A top-level dynamic route (`/:mes`) therefore means only app paths — `/client.<hash>.js` is served as a file, while `/2026-10` goes through the route's middlewares and loaders.
33
+
28
34
  ## Dockerfile
29
35
 
30
36
  Here's a production-ready Dockerfile with multi-stage build:
@@ -124,7 +130,7 @@ If you want to serve static assets (JS, CSS, images) from a CDN or S3 bucket ins
124
130
  STATIC_BASE_URL=https://cdn.example.com/assets npx velojs start
125
131
  ```
126
132
 
127
- The `<Scripts />` component and CSS `url()` references will automatically use this prefix. The server will skip serving static files when `STATIC_BASE_URL` points to an external URL.
133
+ The `<Scripts />` component and CSS `url()` references will automatically use this prefix. When `STATIC_BASE_URL` starts with `http`, the server mounts no local static at all: every path is an app path, and the public-by-design semantics come from the bucket/CDN.
128
134
 
129
135
  ## Included dependencies
130
136
 
@@ -0,0 +1,182 @@
1
+ ---
2
+ name: velojs-telemetry
3
+ description: "Native telemetry: the framework records its own boundaries — the page SSR (with a span per hierarchy loader plus the render), the `?_data=1` refetch, the action, the endpoint, the stream connect, the socket setup, the channel connect and every `emit` — as JSONL lines carrying duration, status, response bytes, child spans and, on long connections, one delivery line per frame plus a closing line. Enable it by destination (`VELO_TELEMETRY_FILE` and/or `VELO_TELEMETRY_SINK_URL`, values expanding `~`/`$VAR`, instance-suffixed file, `VELO_TELEMETRY_MAX_MB` rotation) and rank the file with `velojs telemetry`. Use when a production page is slow, when a response is too fat, or to see which routes, loaders, actions and channels are actually being called."
4
+ ---
5
+
6
+ # Telemetry
7
+
8
+ The framework knows its own bottlenecks — they are finite: a page SSR (with the loaders of the hierarchy), a `?_data=1` refetch, an action, a declarative endpoint, a stream connect, a socket setup, a channel connect. It also knows the part that runs **outside any request**: when `emit()` invalidates a channel, the loader re-executes on an already-open connection, with no new request involved. Native telemetry turns each of those executions into one JSON line — duration, status, response bytes, child spans — and each long connection into one light line per delivery plus a final line when it closes.
9
+
10
+ What this buys you: **which route is slow, how big its response is, and why**. A page trace names every loader that ran and how long each took; an emit trace names the channel, the partition and how many connections re-executed. The file is plain JSONL in an operator-chosen path — no dev overlay, no dashboard inside the app, nothing to attack in production.
11
+
12
+ ## The destination is the trigger
13
+
14
+ There is no on/off flag. **The destination env is the activation**: `VELO_TELEMETRY_FILE` and/or `VELO_TELEMETRY_SINK_URL`, present and non-empty, turn collection on; none of them — absent or empty — leaves the module completely inert (no file, no event, no cost) in **any environment, dev included**. The dev server never turns telemetry on by itself: an app that wants it in development puts the env in its `.env`, like any other environment.
15
+
16
+ The framework never picks a destination for you: there is no default file path anywhere. The path is the deployment's decision, read from the env. The values expand `~` (home) and `$VAR` / `${VAR}`, so an operator can point at `$ALGO_PRIVADO/telemetry.jsonl`; an undefined reference is kept literally, and the [boot record](#the-boot-record) shows the destination that was actually in force.
17
+
18
+ | Variable | Default | Role |
19
+ |----------|---------|------|
20
+ | `VELO_TELEMETRY_FILE` | — | Destination file. Present and non-empty activates the file collection (append-only JSONL). Expands `~` and `$VAR`. No default path |
21
+ | `VELO_TELEMETRY_SINK_URL` | — | Destination sink. Present and non-empty activates the HTTP collection: one `POST` per event with the JSON line as the body |
22
+ | `VELO_TELEMETRY_MAX_MB` | `10` | Rotation limit of the file, in MB. The only pruning mechanism — there is no retention by days |
23
+
24
+ Both destinations at once feed both; one alone is enough. The values are read from the process environment first and from the project's `.env` as the fallback (an env var exported in the shell wins over the file) — the same precedence the release script uses.
25
+
26
+ ```bash
27
+ # .env — production, or a dev machine that wants the traces
28
+ VELO_TELEMETRY_FILE=$DATA_DIR/telemetry.jsonl
29
+ VELO_TELEMETRY_MAX_MB=10
30
+ # ...or ship them somewhere:
31
+ # VELO_TELEMETRY_SINK_URL=https://collector.example.com/ingest
32
+ ```
33
+
34
+ ## What gets recorded
35
+
36
+ Each execution in one of the framework's bottlenecks produces a trace (type → fields):
37
+
38
+ | Type | Produced by | Carries |
39
+ |------|-------------|---------|
40
+ | `page` | A page request (SSR HTML) | `route` (path pattern), `module` (the page's moduleId), duration, status, `bytes`, one child span per loader of the hierarchy (plus the `render` span) |
41
+ | `data` | A `?_data=1` refetch (SPA navigation) | `route`, `module`, duration, status, `bytes` of the JSON, a span per loader executed |
42
+ | `action` | `/_action/{moduleId}/{name}` | `route`, `module`, `name`, duration, status, `bytes` |
43
+ | `endpoint` | A declarative `{ method, handler }` node | `method`, `route`, duration, status, `bytes` where there is a body |
44
+ | `stream-connect` | A `stream_*` / standalone SSE connect | `route`, `module`, `name`, `channel`, duration, status |
45
+ | `socket-connect` | A `socket_*` upgrade | `route`, `module`, `name`, duration, status |
46
+ | `channel-connect` | A live-loader channel connect | `route`, `module`, `channel`, duration, status, the `snapshot` span of the connect snapshot |
47
+ | `emit` | Every `emit()` gesture | `channel`, `partition`, `mode` (`invalidate` / `slice`), `connections` reached, duration; in the invalidation mode, one child span per loader re-execution per connection |
48
+ | `delivery` | Every frame a long connection delivers | `channel`/`route`/`module`, the cumulative ordinal (`deliveries`), `bytes` of the frame |
49
+ | `close` | The end of a long connection | `channel`/`route`/`module`, the total of `deliveries`, the connection's lifetime as `duration` |
50
+
51
+ The emission is **transparent to the app**: no import, no wrapper, no change in a route module. The spans are automatic too — the trace of a page knows the loaders because the framework is the one executing them.
52
+
53
+ ## The event format
54
+
55
+ One JSON object per line. The same envelope for every type:
56
+
57
+ ```json
58
+ {
59
+ "trace": "m3k9x1-4-f8a2q7",
60
+ "ts": "2026-10-07T18:22:03.482Z",
61
+ "type": "page",
62
+ "route": "/:mes",
63
+ "module": "MesPage",
64
+ "status": "ok",
65
+ "duration": 45.2,
66
+ "bytes": 122880,
67
+ "spans": [
68
+ { "name": "loader:AppLayout", "start": 0.8, "duration": 3.1, "status": "ok" },
69
+ { "name": "loader:MesPage", "start": 1.2, "duration": 43.9, "status": "ok" },
70
+ { "name": "render", "start": 44.3, "duration": 3.5, "status": "ok" }
71
+ ]
72
+ }
73
+ ```
74
+
75
+ - **`trace`** identifies the execution (and ties a connection's delivery/close lines to its connect line); **`ts`** is the line's own timestamp — both exist on **every** type, including `delivery`, `close` and `boot`.
76
+ - **`route`** is always the path **pattern** (`/:mes`), never the materialized URL — this is what makes aggregation by route possible. The channel and stream routes travel as their registered paths (`/_channel/{moduleId}/{channel}`, `/_event/{moduleId}/{name}`).
77
+ - **`duration`** is in milliseconds, with decimals; **`status`** is `ok` or `error` (a loader, action or handler that throws). `bytes` is the size of the response body where there is one: pages, data refetches, actions and endpoints. Time and weight travel together, so a slow screen is either a slow server or a fat response — the trace says which.
78
+ - **`spans`** are the children of the trace, when the type has them: `name`, `start` (relative to the trace), `duration` and `status` each. Loader spans are named after the module (`loader:MesPage`); `render` delimits the SSR itself. `total − (loaders + render)` is the gap of the middlewares, which this slice deliberately leaves uninstrumented.
79
+ - **`deliveries`** is the cumulative ordinal of a connection's deliveries. A long connection is a stream of evidence: fifty snapshots on one channel is the symptom of "re-executing too much", and the volume is proportional to what it measures — whoever delivers a lot, records a lot. Rotation limits the rest.
80
+
81
+ ### Delivery and closing lines
82
+
83
+ Long connections (channel, stream and socket) never keep one line growing: each delivered frame is a light line of its own — no spans — carrying the ordinal and the frame's bytes when there are any:
84
+
85
+ ```json
86
+ { "trace": "m3k9x1-4-f8a2q7", "ts": "…", "type": "delivery", "channel": "teamExpenses", "deliveries": 2, "bytes": 31 }
87
+ ```
88
+
89
+ When the connection ends — the client left, the server closed it, the idle timeout elapsed — the final line reports the total and the lifetime:
90
+
91
+ ```json
92
+ { "trace": "m3k9x1-4-f8a2q7", "ts": "…", "type": "close", "channel": "teamExpenses", "deliveries": 2, "status": "ok", "duration": 120000.4 }
93
+ ```
94
+
95
+ Heartbeats are transport, not delivery: a `: ping` comment never counts as a delivery and never moves the idle clock.
96
+
97
+ ### The emit chain
98
+
99
+ The emit trace has **no request parent at all** — that is the point:
100
+
101
+ ```json
102
+ {
103
+ "trace": "m3k9x2-1-b41c9d",
104
+ "ts": "…",
105
+ "type": "emit",
106
+ "channel": "teamExpenses",
107
+ "partition": "team:7",
108
+ "mode": "invalidate",
109
+ "connections": 2,
110
+ "status": "ok",
111
+ "duration": 12.7,
112
+ "spans": [
113
+ { "name": "loader:Expenses", "start": 0.1, "duration": 5.4, "status": "ok" },
114
+ { "name": "loader:Expenses", "start": 0.2, "duration": 6.1, "status": "ok" }
115
+ ]
116
+ }
117
+ ```
118
+
119
+ In the invalidation mode every re-executed loader is a child span — one per connection; a scope that resolves to a partition with no subscribers still produces the trace, with `connections: 0` (an emit that reaches nobody is exactly what you want to see). In the slice mode the producer already holds the value, so the trace carries the connections that received it and no spans. When the coalescing window folds a burst, each gesture that fell inside it gets its trace, carrying the round's spans.
120
+
121
+ ## The boot record
122
+
123
+ The first event of every active destination — the file's first line, the sink's first POST — is the boot record, the cheap "where am I writing and how far can I grow" diagnosis:
124
+
125
+ ```json
126
+ { "trace": "m3k9x0-0-0a1b2c", "ts": "…", "type": "boot", "version": "1.4.2", "env": "production", "file": "/srv/app/logs/telemetry.8123-host.jsonl", "sink": null, "maxMb": 10 }
127
+ ```
128
+
129
+ `version` is the served app's `package.json` version; `env` is `NODE_ENV`; `file` and `sink` are the **effective** destinations (a null one is off), and `maxMb` is the rotation limit in force — the default or the override.
130
+
131
+ ## File, instance and rotation
132
+
133
+ The file destination is append-only JSONL. The **effective name carries an instance suffix** with the pid and the hostname — `telemetry.jsonl` becomes `telemetry.8123-host.jsonl` — so replicas of the same app sharing a volume write different files and a concurrent append never corrupts one another's JSONL.
134
+
135
+ Rotation is by size only (`VELO_TELEMETRY_MAX_MB`, default `10`): when the next line would push the file past the limit, the file is renamed to `<effective>.<n>` and a fresh one takes its place — the effective path always holds the newest lines, and the rotated files stay behind it. There is no retention by days; the limit is the whole pruning policy.
136
+
137
+ ## The sink
138
+
139
+ With `VELO_TELEMETRY_SINK_URL` set (alone or alongside `VELO_TELEMETRY_FILE`), every event also leaves as one `POST` with the JSON line as the body — that is what the consumer's platform ingests. Without the env, nothing leaves the machine. A sink failure — refused connection, non-2xx — is logged and **never** affects the request that produced the event.
140
+
141
+ ## The CLI
142
+
143
+ `velojs telemetry` is the inaugural consumption surface: it ranks the JSONL by route, action and channel — count, errors, p50/p95 and volume in bytes — and prints the timeline. No UI, no server route: the operator's terminal.
144
+
145
+ ```bash
146
+ # The whole file resolved from the env (or the project's .env), instance and
147
+ # rotation files included:
148
+ velojs telemetry
149
+
150
+ # Only the last hour of a long-lived file:
151
+ velojs telemetry --last 60m
152
+
153
+ # A JSONL copied from production:
154
+ velojs telemetry ./telemetry.prod.jsonl
155
+ ```
156
+
157
+ The window is the whole file by default; `--last` accepts `ms`, `s`, `m`, `h` and `d` (`--last 60m`, `--last 2h`, `--last 7d`). Without a path, the destination comes from `VELO_TELEMETRY_FILE` (environment or `.env`), including every file that derives from it — the instance suffixes and the rotated ones. An explicit path reads exactly that file (or a directory of them).
158
+
159
+ ```
160
+ VeloJS telemetry
161
+ files: 1
162
+ /srv/app/logs/telemetry.8123-host.jsonl
163
+ window: last 60m — since 2026-10-07T17:22:03.482Z
164
+ events: 412
165
+
166
+ By route
167
+ key count errors p50 p95 bytes
168
+ /:mes 48 1 45.2ms 88.1ms 5.6MB
169
+ GET /api/health 120 0 1.2ms 3.0ms 12.0KB
170
+
171
+ By action
172
+ key count errors p50 p95 bytes
173
+ Jobs/notify 6 0 12.4ms 30.2ms 1.2KB
174
+
175
+ By channel
176
+ key count errors p50 p95 bytes
177
+ teamExpenses 52 0 12.7ms 20.1ms 1.3KB
178
+ ```
179
+
180
+ ## What is not here yet
181
+
182
+ The visual viewer over the route tree (`velojs telemetry --view`, following the `velojs graph --view` pattern), a manual `trace()` API for app spans, OTLP/OpenTelemetry export, middleware spans, client-side telemetry and the retention/continuous aggregation layer are deliberately out of this slice. The nested format is the one that will receive them without a migration — the model is already parent-and-children.
@@ -57,6 +57,7 @@
57
57
  */
58
58
  import type { Context, Hono, MiddlewareHandler } from "hono";
59
59
  import type { LoaderArgs, RouteModule } from "./types.js";
60
+ import { type TelemetryStatus, type TelemetryTrace } from "./telemetry.js";
60
61
  /** Result of a partition resolver. `null`/`undefined` denies the subscription. */
61
62
  export type ChannelScopeResult = string | null | undefined;
62
63
  /**
@@ -229,6 +230,12 @@ export interface ChannelConnection {
229
230
  * comments never touch it.
230
231
  */
231
232
  lastDeliveryAt?: number | undefined;
233
+ /**
234
+ * The connection's telemetry trace: `channel-connect` with the snapshot
235
+ * span, then one `delivery` line per snapshot/slice and the final `close`
236
+ * line. Absent (no-op) when collection is off.
237
+ */
238
+ telemetry?: TelemetryTrace | undefined;
232
239
  }
233
240
  /** One live group of a channel: the connections of one module in one partition. */
234
241
  export interface ChannelInspectGroup {
@@ -283,7 +290,7 @@ export declare function __resetChannels(): void;
283
290
  * instead of dying silently; a loader that returns a `Response` (a redirect
284
291
  * short-circuit) has no state to send.
285
292
  */
286
- export declare function pushSnapshot(conn: ChannelConnection): Promise<void>;
293
+ export declare function pushSnapshot(conn: ChannelConnection): Promise<TelemetryStatus>;
287
294
  /**
288
295
  * Signals that a channel's partition changed: every connection in that
289
296
  * partition receives a fresh snapshot (its loader re-executed with the