@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 +91 -2
- package/agent/AGENTS.md +2 -0
- package/agent/skills/velojs-live-loader/SKILL.md +3 -1
- package/agent/skills/velojs-production-deploy/SKILL.md +7 -1
- package/agent/skills/velojs-telemetry/SKILL.md +182 -0
- package/dist/channels.d.ts +8 -1
- package/dist/channels.js +282 -227
- package/dist/cli.js +46 -32
- package/dist/events.d.ts +9 -1
- package/dist/events.js +161 -152
- package/dist/server.js +245 -198
- package/dist/sockets.d.ts +4 -1
- package/dist/sockets.js +115 -102
- package/dist/telemetry-cli.d.ts +67 -0
- package/dist/telemetry-cli.js +265 -0
- package/dist/telemetry-node.d.ts +38 -0
- package/dist/telemetry-node.js +93 -0
- package/dist/telemetry.d.ts +204 -0
- package/dist/telemetry.js +240 -0
- package/package.json +1 -1
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.
|
|
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.
|
package/dist/channels.d.ts
CHANGED
|
@@ -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<
|
|
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
|