mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
docs(chord): tutorial chapter on telemetry, with a small trace viewer
This commit is contained in:
parent
0f4d5e960a
commit
0efe50cb6d
17 changed files with 1344 additions and 83 deletions
|
|
@ -2,9 +2,9 @@
|
|||
title: Together: build a shared todo app with Chord
|
||||
cover: Build It / Together
|
||||
kicker: A tutorial · Chord v2 and its app kit · 2026-11-03
|
||||
subtitle: One small app, built a step at a time, from an empty server to two people, a page, a terminal, a theme, three languages and an edit applied while they are connected
|
||||
subtitle: One small app, built a step at a time, from an empty server to two people, a page, a terminal, a theme, three languages, an edit applied while they are connected, and one click traced across its processes
|
||||
stats:
|
||||
- 10 | steps, one runnable example
|
||||
- 11 | steps, one runnable example
|
||||
- 4 | host kinds one plugin runs on
|
||||
- 1 | worker process per list
|
||||
- 0 | restarts for a new theme, a language or an edit
|
||||
|
|
@ -41,7 +41,7 @@ node --conditions=source --experimental-strip-types test/examples/61-tutorial/ma
|
|||
With Node 26.5.1 or later the terminal client of chapter V runs too (its sandbox needs that Node); with an older one,
|
||||
that step says so and the rest runs.
|
||||
|
||||
## Nine things to know
|
||||
## Ten things to know
|
||||
|
||||
:::findings
|
||||
1. **An app is its API, its plugins and a start script.** `startServer` makes the data directory, the store, the
|
||||
|
|
@ -61,8 +61,11 @@ that step says so and the rest runs.
|
|||
server, the workers, the pages and the terminals, in place.
|
||||
8. **Text is rendered per viewer.** A plugin's messages are typed code, a translation is a plugin, each user picks a
|
||||
language, and servers store keys, never sentences.
|
||||
9. **Five traps:** load before use; a reference acts as whoever made it; await what you start; a watch starts at the
|
||||
current value; the plain `t` has no language of its own.
|
||||
9. **One click is one trace.** Every process records spans to the telemetry store it is given (here one SQLite file
|
||||
in the data directory); carriers in the calls' headers join the page's call, the server's relay and the worker's
|
||||
method into one trace, which `TelemetryStore` reads back. `ctx.trace` adds spans of your own.
|
||||
10. **Six traps:** load before use; a reference acts as whoever made it; await what you start; a watch starts at the
|
||||
current value; the plain `t` has no language of its own; a trace starts where a span starts.
|
||||
:::
|
||||
|
||||
## Words used throughout
|
||||
|
|
@ -76,7 +79,8 @@ are the member sets different principals get. A **factory** declares an object's
|
|||
(values computed from others), **natives** (things opened and closed, such as a file or a database cursor). A **reference** is what asking for an object returns. A **facet** is a
|
||||
plugin's code for one host kind; a **registry** is an extension point plugins **contribute** to. A **message** is a
|
||||
text with a key, a **catalog** a plugin's messages in one language, a **locale** a language tag (`de`, `pt-BR`). A **rollout** applies
|
||||
new plugin code to the hosts in order.
|
||||
new plugin code to the hosts in order. A **span** is one timed piece of work a host recorded, a **trace** the spans of
|
||||
one request across hosts, joined by the **carriers** calls carry.
|
||||
|
||||
# One Server {#server lede="The API, the principals and a start script: the smallest app the kit runs, and who may come in."}
|
||||
|
||||
|
|
@ -212,7 +216,12 @@ export const KINDS = {
|
|||
|
||||
<!-- quote: start.ts -->
|
||||
```ts
|
||||
export function startTodo(dataDir: string, root: string, context: Context): Promise<App<TodoPrincipal>> {
|
||||
export function startTodo(
|
||||
dataDir: string,
|
||||
root: string,
|
||||
telemetry: TodoTelemetry,
|
||||
context: Context,
|
||||
): Promise<App<TodoPrincipal>> {
|
||||
return startServer<TodoPrincipal>(
|
||||
{
|
||||
name: "todo",
|
||||
|
|
@ -222,17 +231,21 @@ export function startTodo(dataDir: string, root: string, context: Context): Prom
|
|||
api: API,
|
||||
kinds: KINDS,
|
||||
principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", list: address.key }) },
|
||||
// The server's spans and metrics; each list's worker opens the same file (`worker.ts`).
|
||||
telemetry: telemetry.store,
|
||||
workers: [
|
||||
{
|
||||
kind: v1.List,
|
||||
entry: fileURLToPath(new URL("./worker.ts", import.meta.url)),
|
||||
execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"],
|
||||
env: { TODO_TELEMETRY: telemetryFile(dataDir) },
|
||||
start: () => ({}),
|
||||
// Who may reach a list's worker: everyone signed in, and the server itself.
|
||||
authorize: (principal) => principal.kind !== "worker",
|
||||
},
|
||||
],
|
||||
web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page },
|
||||
// The page, and the owner's trace viewer: the same page, which shows the viewer at `/traces`.
|
||||
web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page, paths: ["/", "/traces"] },
|
||||
tui: { entry: fileURLToPath(new URL("./src/tui/main.ts", import.meta.url)) },
|
||||
onError: (error) => console.error("todo:", error.kind, error.error),
|
||||
},
|
||||
|
|
@ -241,18 +254,22 @@ export function startTodo(dataDir: string, root: string, context: Context): Prom
|
|||
}
|
||||
```
|
||||
|
||||
`workers` is chapter II, `web` chapter IV, `tui` chapter V. `listen` asks for a free port on loopback, as the example
|
||||
does; a real app picks a port. The worker entry is three lines:
|
||||
`workers` is chapter II, `web` chapter IV (its second path, `/traces`, chapter XI), `tui` chapter V, `telemetry`
|
||||
chapter XI. `listen` asks for a free port on loopback, as the example does; a real app picks a port. The worker entry
|
||||
is a few lines (its telemetry store is chapter XI's too):
|
||||
|
||||
<!-- quote: worker.ts -->
|
||||
```ts
|
||||
// The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects
|
||||
// to the server and loads the plugins' worker facets.
|
||||
// to the server and loads the plugins' worker facets. The worker records to the app's telemetry store, the file its
|
||||
// server named in the environment: its methods called through the server continue the caller's trace.
|
||||
|
||||
import { startWorker } from "../../../src/node/app/index.ts";
|
||||
import { openTelemetry } from "./src/telemetry.ts";
|
||||
import { API, KINDS } from "./start.ts";
|
||||
|
||||
startWorker({ api: API, kinds: KINDS, onError: (error) => console.error("list:", error) });
|
||||
const telemetry = await openTelemetry(process.env.TODO_TELEMETRY!);
|
||||
startWorker({ api: API, kinds: KINDS, telemetry: telemetry.store, onError: (error) => console.error("list:", error) });
|
||||
```
|
||||
|
||||
## Running it
|
||||
|
|
@ -262,7 +279,8 @@ link:
|
|||
|
||||
<!-- quote: main.ts -->
|
||||
```ts
|
||||
const app = await startTodo(dataDir, root, context);
|
||||
const telemetry = await openTelemetry(telemetryFile(dataDir));
|
||||
const app = await startTodo(dataDir, root, telemetry, context);
|
||||
const link = new URL(app.ownerLink());
|
||||
const url = link.origin;
|
||||
console.log("1. owner link:", `http://127.0.0.1:<port>${link.pathname}#<token>`);
|
||||
|
|
@ -281,14 +299,16 @@ console.log(" GET / signed in:", (await fetch(url, { headers: { cookie } })).s
|
|||
<!-- output: main -->
|
||||
```text
|
||||
1. owner link: http://127.0.0.1:<port>/_chord/owner#<token>
|
||||
data directory: logs, owner-token, plugins, server, workers
|
||||
data directory: logs, owner-token, plugins, server, telemetry.sqlite, telemetry.sqlite-shm, telemetry.sqlite-wal, workers
|
||||
GET / signed out: 401
|
||||
the owner signs in: 200 chord-session=…
|
||||
GET / signed in: 200
|
||||
```
|
||||
|
||||
`startServer` made the data directory: `server/` (its store, the cookie secret, its files), `workers/` (one directory
|
||||
per worker), `plugins/` (the user's plugin root, beside the app's built-in one), `logs/` and `owner-token`. The page
|
||||
per worker), `plugins/` (the user's plugin root, beside the app's built-in one), `logs/` and `owner-token`;
|
||||
`telemetry.sqlite` (with SQLite's `-shm` and `-wal` files) is the app's own, opened before the server starts
|
||||
(chapter XI). The page
|
||||
answered 401: HTTP **denies by default**, before any plugin code runs, unless a route says `public: true`.
|
||||
|
||||
The **owner link** (`/_chord/owner#<token>`) is how the owner gets in: a single-use token, valid ten minutes, made at
|
||||
|
|
@ -376,6 +396,7 @@ it on to its parent or to a route (design §3, "Host kinds and addresses"). The
|
|||
kind: v1.List,
|
||||
entry: fileURLToPath(new URL("./worker.ts", import.meta.url)),
|
||||
execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"],
|
||||
env: { TODO_TELEMETRY: telemetryFile(dataDir) },
|
||||
start: () => ({}),
|
||||
// Who may reach a list's worker: everyone signed in, and the server itself.
|
||||
authorize: (principal) => principal.kind !== "worker",
|
||||
|
|
@ -383,7 +404,8 @@ it on to its parent or to a route (design §3, "Host kinds and addresses"). The
|
|||
],
|
||||
```
|
||||
|
||||
`authorize` runs for every request routed to a list before anything starts; the first request for an address starts
|
||||
`env` names the app's telemetry store for the worker (chapter XI). `authorize` runs for every request routed to a
|
||||
list before anything starts; the first request for an address starts
|
||||
its worker, and a worker idle for five minutes (the default `idleMs`) stops. **Variants** are the member sets a
|
||||
contract offers: the owner's `Todos` has `clear`, a member's does not. The factory picks one per principal.
|
||||
|
||||
|
|
@ -463,8 +485,11 @@ else in memory, not even the next todo's number, which it reads in the transacti
|
|||
<!-- quote: plugins/lists/worker.ts -->
|
||||
```ts
|
||||
add: async ({ text, by }, ctx) => {
|
||||
// A span of its own under the method's: a trace shows the check, and why it refused.
|
||||
await ctx.trace("todos.validate", { chars: text.length }, async () => {
|
||||
// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.
|
||||
if (text === "") throw invalid("args", "a todo needs text");
|
||||
});
|
||||
await self.tx(async (tx) => {
|
||||
// The number after the newest todo's: the store's transactions run one after another.
|
||||
const { items: last } = await tx.page(items, NEWEST, 1);
|
||||
|
|
@ -475,7 +500,7 @@ else in memory, not even the next todo's number, which it reads in the transacti
|
|||
|
||||
A todo without text is refused with `invalid("args", …)`, from the API: the error crosses every link as it is, so a
|
||||
caller on a page or in a terminal gets `{ code: "invalid", reason: "args" }`, as for arguments that fail the
|
||||
contract's schema.
|
||||
contract's schema. The check runs in a span of its own (`ctx.trace`), which chapter XI shows in a trace.
|
||||
|
||||
## The view per principal
|
||||
|
||||
|
|
@ -663,6 +688,8 @@ const core = defineFacet((host) => host.serve(v1web.Panels, { compose: (table) =
|
|||
|
||||
function Page() {
|
||||
const panels = useRegistry(v1web.Panels) ?? [];
|
||||
// The page's one other path (`start.ts` serves the page at `/` and `/traces`).
|
||||
if (location.pathname === "/traces") return h("main", { className: "wide" }, h("h1", null, "Todo"), h(TraceViewer));
|
||||
return h(
|
||||
"main",
|
||||
null,
|
||||
|
|
@ -679,8 +706,9 @@ void startPage({
|
|||
});
|
||||
```
|
||||
|
||||
`startPage` makes the page's host, connects it to `/chord` (reconnecting after drops), loads the current plugin
|
||||
scripts with the entry's core facets, and keeps them current. `HostProvider` gives the React tree that host.
|
||||
The path `/traces` is the owner's trace viewer (chapter XI). `startPage` makes the page's host, connects it to `/chord`
|
||||
(reconnecting after drops), loads the current plugin scripts with the entry's core facets, and keeps them current.
|
||||
`HostProvider` gives the React tree that host.
|
||||
|
||||
## The web facet
|
||||
|
||||
|
|
@ -1415,6 +1443,339 @@ the server takes the worker's process lock and reads the store itself. `importDa
|
|||
replacing a list is a delete, then an import (decision "App kit naming and export"). Registering an imported list in
|
||||
an index of the app's, if it had one, is the app's.
|
||||
|
||||
# Seeing What Happened {#telemetry lede="One telemetry store for every process, one trace from a click to a commit, a span of your own, and a small viewer for the owner."}
|
||||
|
||||
Ada says that adding a todo from her phone feels slow some evenings. Her todo crosses three processes: her page, the
|
||||
server's relay and the list's worker. A log line per process would say what each one did, but not which lines belong
|
||||
to her click, or where the time went. Chord records **telemetry** through pi-telemetry: **spans** (a named, timed
|
||||
piece of work with attributes), **events** on spans, and **metrics** (counters and histograms). Each host has one
|
||||
**adapter**, given to `createHost({ telemetry })` and NOOP by default, which decides where they go. A **carrier** in
|
||||
each call's header names the caller's span, so the spans of one request, recorded by different processes, form one
|
||||
**trace** (design §29). Mara wants to see them without reading SQL: a small trace viewer would be nice. This chapter
|
||||
records, follows one click, adds a span, reads the store from a script, and ends with the viewer.
|
||||
|
||||
## Recording
|
||||
|
||||
The app keeps its telemetry in one SQLite file in the data directory, through Chord's store
|
||||
(`@earendil-works/chord/telemetry-sqlite`). The app's base code opens it, for any process that asks:
|
||||
|
||||
<!-- quote: src/telemetry.ts -->
|
||||
```ts
|
||||
/** The telemetry store's file in the data directory `dataDir`. */
|
||||
export function telemetryFile(dataDir: string): string {
|
||||
return join(dataDir, "telemetry.sqlite");
|
||||
}
|
||||
|
||||
/** Opens the telemetry store at `file` for this process (its directory made, private, when missing). */
|
||||
export async function openTelemetry(file: string): Promise<TodoTelemetry> {
|
||||
// Spans carry who called what: the data directory is private before the store's first file appears in it.
|
||||
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
||||
const db = await openNodeSqliteDatabase(file);
|
||||
const store = await createSqliteTelemetry(db);
|
||||
return {
|
||||
store,
|
||||
close: async () => {
|
||||
await store.close();
|
||||
await db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`createSqliteTelemetry` returns a **recorder**: an adapter that keeps each settled span in memory and writes them in
|
||||
one batch a second, one row per span with its events and links inline; metrics are summed per series and 10 seconds;
|
||||
rows older than seven days are deleted in the same batch, and when 10,000 spans wait, new ones are dropped and counted.
|
||||
Recording never runs SQL. The same object is the store's **read interface**, `TelemetryStore` (below).
|
||||
|
||||
Step 1 opened the store before the server (chapter I), and `startTodo` gives it to `startServer`, which passes it to the
|
||||
server's host and its HTTP component:
|
||||
|
||||
<!-- quote: start.ts -->
|
||||
```ts
|
||||
// The server's spans and metrics; each list's worker opens the same file (`worker.ts`).
|
||||
telemetry: telemetry.store,
|
||||
```
|
||||
|
||||
The kit carries no spans between processes: each process records to the adapter it was given (slice 9, §5). So each
|
||||
list's worker opens the same file with a recorder of its own; the server names the file in the worker's environment:
|
||||
|
||||
<!-- quote: start.ts -->
|
||||
```ts
|
||||
env: { TODO_TELEMETRY: telemetryFile(dataDir) },
|
||||
```
|
||||
|
||||
<!-- quote: worker.ts -->
|
||||
```ts
|
||||
const telemetry = await openTelemetry(process.env.TODO_TELEMETRY!);
|
||||
startWorker({ api: API, kinds: KINDS, telemetry: telemetry.store, onError: (error) => console.error("list:", error) });
|
||||
```
|
||||
|
||||
One file for every process is the app's choice. SQLite in WAL mode lets several processes write it, each waiting for
|
||||
the others' locks (up to 5 seconds), and every span id is global, so rows of different processes never collide. An app
|
||||
with a collector would give each process an adapter that exports there instead; nothing else changes.
|
||||
|
||||
## One trace across three hosts
|
||||
|
||||
A page in a browser cannot record yet: `startPage` takes no telemetry adapter. Step 11 makes the page itself, as Ada's
|
||||
client was made in chapter VII: a client host of this process with a recorder of its own on the same file, signed in
|
||||
with the owner's cookie from step 1. A click is one span of the page, and the call runs under it:
|
||||
|
||||
<!-- quote: main.ts -->
|
||||
```ts
|
||||
const pageTelemetry = await openTelemetry(telemetryFile(dataDir));
|
||||
const page = createHost<TodoPrincipal>({
|
||||
telemetry: pageTelemetry.store,
|
||||
onError: (error) => console.error("page:", error.kind),
|
||||
});
|
||||
await page.load([], context);
|
||||
const pageSocket = new WebSocket(`${url.replace(/^http/, "ws")}/chord`, { headers: { cookie } });
|
||||
page.connect(webSocketLink(pageSocket as unknown as WebSocketLike), { principal: { kind: "server" } }, context);
|
||||
const pageTodos = await page.get(Todos, { list: "groceries" }, context);
|
||||
const click = (text: string) =>
|
||||
pageTelemetry.store.startSpan({ name: "page.add" }, (span) => pageTodos.add(text, withTelemetry(span, context)));
|
||||
await click("honey");
|
||||
await click(" ").catch(() => "refused");
|
||||
```
|
||||
|
||||
`startSpan` runs the callback in a new root span; `withTelemetry(span, context)` makes it the context's span, so the
|
||||
call of `add` starts its `chord.call` under it and puts that span's carrier in the call's header. Each recorder writes
|
||||
once a second, so the step reads the store until the worker's spans are there, takes the older of the two traces whose
|
||||
first span is `page.add` and prints it with pi-telemetry's `renderTrace` (main.ts masks the lengths of time, which
|
||||
differ at every run):
|
||||
|
||||
<!-- output: main -->
|
||||
```text
|
||||
11. adding honey from the page:
|
||||
page.add
|
||||
chord.call type="todo.todos" member="add" object="todo.todos(groceries)" address.kind="list" outcome="ok"
|
||||
chord.relay type="todo.todos" member="#0" upstream.kind="list" outcome="ok"
|
||||
chord.method type="todo.todos" member="add" object="todo.todos(groceries)" principal.kind="owner" outcome="ok"
|
||||
chord.method type="todo.store" member="add" object="todo.store" principal.kind="worker" outcome="ok"
|
||||
· chord.commit documents=0 ops=0 duration=<ms>
|
||||
todos.validate chars=5
|
||||
```
|
||||
|
||||
Three processes recorded these six spans. The page recorded `page.add` and `chord.call`, the consumer's call until its
|
||||
reply. The server recorded `chord.relay`: it forwarded the call to the list's worker without knowing member names
|
||||
(`#0` is the method's index). The worker recorded the rest: `chord.method` for the owner's `Todos` view, then the
|
||||
view's call of the store, which is a method span too, by `worker`, because the store acts as its host (chapter II).
|
||||
The commit is an event on the span its transaction ran under, and the renderer prints a span's events before its
|
||||
children. Its counts are documents and their ops; a commit of table rows counts neither, so this one says 0 and 0.
|
||||
|
||||
:::sequence animate | One click, one trace: each process starts its spans with its own recorder, and the carrier in each call's header names the parent span on the other side.
|
||||
actor page "page" "recorder 1"
|
||||
actor server "server" "recorder 2"
|
||||
actor worker "list|groceries" "recorder 3"
|
||||
page -> page: page.add, chord.call
|
||||
page => server: call add, carrier of chord.call
|
||||
server -> server: chord.relay
|
||||
server => worker: call #0, carrier of chord.relay
|
||||
worker -> worker: chord.method ×2, todos.validate, commit
|
||||
worker --> server: reply
|
||||
server --> page: reply
|
||||
:::
|
||||
|
||||
The carrier is a field of the `call` and `hold` headers, at most 8 keys of at most 256 characters each. The receiver
|
||||
reads it with its own adapter; an absent or malformed one gives a new root, never an error. With the NOOP adapter
|
||||
nothing is injected and no span starts: a span site costs one check (§30).
|
||||
|
||||
The trace holds the click and nothing else. Work that serves many requests is its own trace, **linked** to the span that
|
||||
first needed it: a worker's start (`chord.resolve`), an object's activation (`chord.activate`). A link is a reference to
|
||||
a span of another trace; the renderer prints it and never follows it.
|
||||
|
||||
## Trap: a trace starts where a span starts
|
||||
|
||||
The page took its reference to `Todos` before the click, under `BACKGROUND_CONTEXT`, and that hold is in no trace of
|
||||
the page: **a context without a recording span records nothing.** A host gives such work no span of its own, so its
|
||||
calls start no `chord.call` and send no carrier, and the provider's `chord.method` becomes a root of its own trace
|
||||
(the server's relay of the page's hold is one: `chord.relay` with `member="hold"`). So were the server's calls in step 2:
|
||||
on the worker each is a root `chord.method`. Start a span where the work begins, a click handler or a job, and pass it
|
||||
down with `withTelemetry`. The kit's HTTP component does this for every request: a `chord.http` span (method, route
|
||||
pattern, status), with the route's `ctx` under it.
|
||||
|
||||
## A span of your own
|
||||
|
||||
The store's `add` checks the text in a span of its own:
|
||||
|
||||
<!-- quote: plugins/lists/worker.ts -->
|
||||
```ts
|
||||
await ctx.trace("todos.validate", { chars: text.length }, async () => {
|
||||
// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.
|
||||
if (text === "") throw invalid("args", "a todo needs text");
|
||||
});
|
||||
```
|
||||
|
||||
`ctx.trace(name, attributes, fn)` starts a child of the method's span and gives `fn` a child `ctx` with the same
|
||||
principal and `get`, so calls made inside are parented under it. It is the last line of the trace above:
|
||||
`todos.validate chars=5`. Its attributes are small, flat values; the todo's text is data and stays out of telemetry,
|
||||
its length is enough. When `fn` throws, the span fails and the error passes through as it was: the caller still gets
|
||||
`invalid/args`. A check this small does not need a span of its own; a lookup, a model request or a file read does.
|
||||
|
||||
## Reading
|
||||
|
||||
The store is read through `TelemetryStore`, pi-telemetry's read interface modeled on Jaeger's: `findTraces` (complete
|
||||
traces, newest first, filtered by their first span's name, attributes, errors or a time window, paged by a cursor),
|
||||
`getTrace(id)` and `findMetrics` (a metric's rows, one per series and 10 seconds, with count, sum, min and max).
|
||||
`trace.ts` is a script for a data directory, the app running or not:
|
||||
|
||||
<!-- quote: trace.ts -->
|
||||
```ts
|
||||
if (id !== undefined) {
|
||||
const trace = await telemetry.store.getTrace(id);
|
||||
console.log(trace === undefined ? `no trace ${id}` : renderTrace(trace.spans));
|
||||
} else {
|
||||
// Complete traces, newest first; a trace's first span (its root, by start time) names it.
|
||||
const { traces } = await telemetry.store.findTraces({ name: values.name, limit: 10 });
|
||||
for (const { traceId, spans } of traces) {
|
||||
const start = Math.min(...spans.map((span) => span.startedMs));
|
||||
const length = Math.max(...spans.map((span) => span.endedMs)) - start;
|
||||
const failed = spans.some((span) => span.status.status === "error") ? " error" : "";
|
||||
console.log(`${traceId} ${spans[0]!.name} ${spans.length} spans ${length.toFixed(2)} ms${failed}`);
|
||||
}
|
||||
// Metrics are stored per series and 10 s: a series is one set of attributes, here the direction.
|
||||
for (const direction of ["sent", "received"]) {
|
||||
const rows = await telemetry.store.findMetrics({ name: "chord.link.bytes", attributes: { direction } });
|
||||
const bytes = rows.reduce((sum, row) => sum + row.sum, 0);
|
||||
const messages = rows.reduce((sum, row) => sum + row.count, 0);
|
||||
console.log(`chord.link.bytes ${direction}: ${bytes} bytes in ${messages} messages`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Step 11 runs it twice from a process of its own: the newest `page.add` traces, then the refused one by its id (ids,
|
||||
lengths and counts masked):
|
||||
|
||||
<!-- output: main -->
|
||||
```text
|
||||
trace.ts <data directory> --name page.add:
|
||||
<id> page.add 6 spans <ms> error
|
||||
<id> page.add 6 spans <ms>
|
||||
chord.link.bytes sent: <n> bytes in <n> messages
|
||||
chord.link.bytes received: <n> bytes in <n> messages
|
||||
trace.ts <data directory> <the refused one's id>:
|
||||
page.add error ChordError: a todo needs text
|
||||
chord.call type="todo.todos" member="add" object="todo.todos(groceries)" address.kind="list" outcome="error" error call_failed: a todo needs text
|
||||
chord.relay type="todo.todos" member="#0" upstream.kind="list" outcome="error" error relay_failed: a todo needs text
|
||||
chord.method type="todo.todos" member="add" object="todo.todos(groceries)" principal.kind="owner" outcome="error" error method_failed: a todo needs text
|
||||
chord.method type="todo.store" member="add" object="todo.store" principal.kind="worker" outcome="error" error method_failed: a todo needs text
|
||||
todos.validate chars=0 error trace_failed: a todo needs text
|
||||
```
|
||||
|
||||
A trace's id is its root span's. The refused add failed on every span of its path, each with a fixed category
|
||||
(`call_failed`, `relay_failed`, `method_failed`, `trace_failed`) and the message cut at 500 characters; a cancelled call
|
||||
is not an error. It has no commit: nothing was written. `chord.link.bytes` counts the encoded messages of every host's
|
||||
links, each message once where it was sent and once where it was received. A metric carries no ids, only attributes
|
||||
with few values (`direction`), so its series stay few. A running recorder writes a metric's current 10 seconds only
|
||||
once they are over, or when it closes: `trace.ts` sees the page's bytes, whose recorder closed, and the server's and
|
||||
workers' of the intervals already over. The script is a recorder too, which records nothing; Chord ships only the
|
||||
store, so reads go through a recorder of the file.
|
||||
|
||||
## A trace viewer
|
||||
|
||||
Two halves make the viewer: routes on the server that read the store, and a page that shows what they answer. Server
|
||||
code of an app is either the start script or a plugin, and routes come from plugins (chapter III), so the routes are a
|
||||
small plugin, `plugins/traces/`. Its server facet serves one host-lifetime object, which opens the store's file as a
|
||||
native and contributes the routes while it is live:
|
||||
|
||||
<!-- quote: plugins/traces/server.ts -->
|
||||
```ts
|
||||
host.serve(Traces, (_params, self) => {
|
||||
const info = self.get(AppInfo, {});
|
||||
// The server records to the same file with its own recorder; this one only reads.
|
||||
const telemetry = self.open(
|
||||
async () => openTelemetry(telemetryFile(info.dataDir.value)),
|
||||
(opened) => opened.close(),
|
||||
);
|
||||
self.contribute(Http, {
|
||||
routes: {
|
||||
"GET /api/traces": async (_request, ctx) => {
|
||||
ownerOnly(ctx);
|
||||
const { traces } = await telemetry.value.store.findTraces({ limit: RECENT });
|
||||
return { kind: "json", value: traces.map(({ traceId, spans }) => summaryOf(traceId, spans)) };
|
||||
},
|
||||
"GET /api/traces/:id": async (request, ctx) => {
|
||||
ownerOnly(ctx);
|
||||
const trace = await telemetry.value.store.getTrace(request.params.id!);
|
||||
if (trace === undefined) return { kind: "error", status: 404 };
|
||||
return { kind: "json", value: { id: trace.traceId, spans: rowsOf(trace.spans) } };
|
||||
},
|
||||
},
|
||||
});
|
||||
return {};
|
||||
});
|
||||
```
|
||||
|
||||
The kit gives a plugin the server's process values as objects: `AppInfo` names the data directory, and the API's server
|
||||
module re-exports it with the app's `openTelemetry`. `self.open` declares a native, opened once `AppInfo` is live and
|
||||
closed when the object ends; a reload of the plugin restarts the object and opens the file anew. An object may
|
||||
contribute when it lives as long as its host (`local`, empty params; decision R21). Spans name who called what, so
|
||||
only the owner reads them:
|
||||
|
||||
<!-- quote: plugins/traces/server.ts -->
|
||||
```ts
|
||||
function ownerOnly(ctx: Ctx): void {
|
||||
if (ctx.principal?.kind !== "owner") throw forbidden("the traces are the owner's");
|
||||
}
|
||||
```
|
||||
|
||||
A route's principal is `undefined` only on a public route, which these are not; a signed-in member gets 403. `rowsOf`
|
||||
orders a trace's spans as a tree, parents before their children, and turns attributes, events and links into text. The
|
||||
spans name no host; the SQLite store's span ids begin with the id of the recorder, one per process, and the viewer
|
||||
calls each recorder a host:
|
||||
|
||||
<!-- quote: plugins/traces/server.ts -->
|
||||
```ts
|
||||
function processOf(span: StoredSpan): string {
|
||||
return span.id.slice(0, span.id.lastIndexOf(":"));
|
||||
}
|
||||
```
|
||||
|
||||
That is a detail of this store, not of the read interface (see the epilogue). The page is the app's own page at a
|
||||
second path: `start.ts` serves it at `/` and `/traces` (chapter I), and the web entry picks the viewer by the path:
|
||||
|
||||
<!-- quote: src/web/main.ts -->
|
||||
```ts
|
||||
// The page's one other path (`start.ts` serves the page at `/` and `/traces`).
|
||||
if (location.pathname === "/traces") return h("main", { className: "wide" }, h("h1", null, "Todo"), h(TraceViewer));
|
||||
```
|
||||
|
||||
`TraceViewer` (`src/web/traces.ts`) fetches the list and the trace chosen in the URL's fragment, and draws each span
|
||||
with its host, its length as a bar on the trace's time line, its attributes and events, a failed span in the theme's
|
||||
error colour, and each link as the ids it refers to, never fetched. The bars are positioned with inline styles, which
|
||||
the page's CSP allows only because React sets them through the DOM:
|
||||
|
||||
<!-- quote: src/web/traces.ts -->
|
||||
```ts
|
||||
const bar = {
|
||||
left: `${(row.offsetMs / total) * 100}%`,
|
||||
width: `${Math.max((row.durationMs / total) * 100, 0.5)}%`,
|
||||
};
|
||||
```
|
||||
|
||||
`screenshots.ts` adds honey from a recording page and refuses a blank todo, as step 11 does, then opens the viewer on
|
||||
honey's trace:
|
||||
|
||||
<!-- quote: screenshots.ts -->
|
||||
```ts
|
||||
await browser.open(`${origin}/traces#${encodeURIComponent(trace.traceId)}`);
|
||||
await browser.wait("document.querySelectorAll('.traces-tree li').length === 6");
|
||||
await shoot("traces.png");
|
||||
```
|
||||
|
||||
<!-- output: screenshots -->
|
||||
```text
|
||||
wrote docs/tutorial/screens/traces.png
|
||||
the viewer shows page.add: 6 spans on 3 hosts
|
||||
```
|
||||
|
||||
:::figure wide | The owner's trace viewer at `/traces`, in the Harbor theme: the newest traces (the refused add marked in red, the rest the app's own work: relays, activations, HTTP requests), and honey's trace as a tree across the page, the server and the worker. Starts are stored in whole milliseconds, so the bars of spans this short shift by up to one.
|
||||

|
||||
:::
|
||||
|
||||
The list is mostly the app's own work: each HTTP request, relayed hold and activation is a trace of its own, as the
|
||||
trap above says. A real viewer would filter by name (`findTraces({ name })`) or by error (`errors: true`), both of
|
||||
which the read interface has; this one is about 270 lines of TypeScript in two files and 20 of CSS, and no Chord API was added for it.
|
||||
|
||||
# Testing One Plugin {#testing lede="testPlugin builds a plugin as production does and loads it alone on a host of its own."}
|
||||
|
||||
The steps above test the whole app. Most rules are checked faster on one plugin. `testPlugin` builds the plugin as
|
||||
|
|
@ -1618,13 +1979,15 @@ p2 with the owner's Reload button or `npm run reload -- scratchpad`; a new plugi
|
|||
reload. The trap of chapter II applies with p2's names: a server method that must reach a session's worker as the
|
||||
server runs in a root of the server's own (`serverOf(self)().root`), not through its caller's `ctx.get`.
|
||||
|
||||
# Traps and Open Questions {.epilogue lede="The five traps once more, and what in Chord v2 was hard to explain while writing this."}
|
||||
# Traps and Open Questions {.epilogue lede="The six traps once more, and what in Chord v2 was hard to explain while writing this."}
|
||||
|
||||
**The traps.** Load before use: a host binds nothing before `load`; a client with nothing to load calls `load([])`. A
|
||||
reference acts as whoever made it: `ctx.get` as the caller, `host.get` and a local object's `self.get` as the host.
|
||||
Await what you start: a write started with a context that ends is cancelled and reported `transaction_cancelled`. A
|
||||
watch starts at the current value: read `handle.value`, then `start`; the listener gets changes only. The plain `t`
|
||||
has no language of its own: components and screens render with `useMessages(t)` and `scope.messages(t)`.
|
||||
has no language of its own: components and screens render with `useMessages(t)` and `scope.messages(t)`. A trace
|
||||
starts where a span starts: work under a context without a recording span records nothing, and its provider's span is
|
||||
a root of its own.
|
||||
|
||||
**Found while writing the example, and fixed in Chord since.** Integer table keys were typed `string` (a leftover
|
||||
of the switch from TypeBox), so `tx.update(items, { id }, ...)` with a number failed `tsc`; the store now uses it. A
|
||||
|
|
@ -1634,6 +1997,15 @@ chapter I), plugins could not throw Chord's `invalid` (Chord exports `invalid` a
|
|||
live window over a table needed a one-time read in `self.open`, which made the store restart at every reload of its
|
||||
plugin (now `self.query`, a live query Chord runs, chapters II and IX).
|
||||
|
||||
**Found while writing the telemetry chapter, still open.** A page cannot record: `startPage` takes no telemetry
|
||||
adapter, so chapter XI's page is a client host in Node, and a browser's click starts its trace at the server's relay.
|
||||
`chord.commit` counts documents and their ops only, so a commit that wrote table rows reports `documents=0 ops=0`.
|
||||
Spans name no host, and the viewer tells processes apart by the SQLite store's id format (`<recorder>:<n>`), which
|
||||
the read interface does not promise. The store keeps a span's start in whole milliseconds (`Date.now()`) and its
|
||||
length in fractions (`performance.now()`), so on a trace of a few milliseconds the viewer's bars shift by up to one. And
|
||||
reading needs a recorder: `chord/telemetry-sqlite` exports no read-only
|
||||
store, so a script or a viewer opens a recorder that records nothing.
|
||||
|
||||
# The App's Files {.appendix}
|
||||
|
||||
| File | Kind | What it is |
|
||||
|
|
@ -1641,17 +2013,21 @@ plugin (now `self.query`, a live query Chord runs, chapters II and IX).
|
|||
| `src/principals.ts` | base | the principal type |
|
||||
| `package.json` | base | the API as the workspace package `@todo/app`, its `exports` |
|
||||
| `src/api/v1/index.ts` | base, every kind | `@todo/app/v1`: Chord's part (with `invalid`, `forbidden`, `defineMessages`, `Translations`), `defineFacet`, `List` |
|
||||
| `src/api/v1/server.ts` | base, server | `Http`, the ephemeral `login` |
|
||||
| `src/api/v1/server.ts` | base, server | `Http`, the ephemeral `login`, `AppInfo`, `openTelemetry` |
|
||||
| `src/api/v1/web.ts` | base, web | `Panels`, `useHold`, `useMessages`, `usePageState`, `useWatch` |
|
||||
| `src/api/v1/tui.ts` | base, tui | `Screens` |
|
||||
| `start.ts`, `worker.ts` | base | `startServer`, `startWorker` |
|
||||
| `src/web/main.ts`, `src/web/style.css` | base, web entry | `startPage`, the panels |
|
||||
| `start.ts`, `worker.ts` | base | `startServer`, `startWorker`, each with the telemetry store |
|
||||
| `src/telemetry.ts` | base | the telemetry store's file in the data directory, `openTelemetry` |
|
||||
| `src/web/main.ts`, `src/web/style.css` | base, web entry | `startPage`, the panels, the viewer at `/traces` |
|
||||
| `src/web/traces.ts` | base, web entry | the owner's trace viewer |
|
||||
| `src/tui/main.ts` | base, terminal entry | `startTerminalRuntime`, the screens |
|
||||
| `plugins/lists/` | plugin | contract (`Item`, `Notice`, `Todos`), messages (English, German), worker (table, store, views), server (routes), web (panel), tui (screen), its test |
|
||||
| `plugins/french/` | plugin | French for the lists plugin, for pages and terminals |
|
||||
| `plugins/login.server.ts` | single-file plugin | the kit's ephemeral login |
|
||||
| `plugins/traces/` | plugin | the trace viewer's routes over the telemetry store (server), the owner's only |
|
||||
| `later/harbor/` | plugin, added in step 6 | the Harbor theme for pages and terminals |
|
||||
| `main.ts`, `screenshots.ts`, `tutorial.test.ts` | example | the steps, the screenshots, the checks |
|
||||
| `trace.ts` | example | a data directory's newest traces, one trace, a metric |
|
||||
|
||||
# Reference {.appendix}
|
||||
|
||||
|
|
@ -1675,4 +2051,8 @@ plugin (now `self.query`, a live query Chord runs, chapters II and IX).
|
|||
| `MessagesOf<typeof t>`, `LocaleChoice` | VIII | a translation plugin, the user's language |
|
||||
| `app.plugins.state`, `app.applyChanges(ctx)` | VI, IX | marks and rollouts |
|
||||
| `app.exportData(address, path, ctx)`, `app.importData(path, address, ctx)` | X | moving a worker's data |
|
||||
| `testPlugin(dir, { kind, api, modules, address, principal, stubs }, ctx)` | XI | one plugin alone |
|
||||
| `createSqliteTelemetry(db)`, `startServer({ telemetry })`, `startWorker({ telemetry })`, `createHost({ telemetry })` | XI | recording to the telemetry store |
|
||||
| `adapter.startSpan({ name }, fn)`, `withTelemetry(span, ctx)`, `ctx.trace(name, attributes, fn)` | XI | spans of your own |
|
||||
| `store.findTraces({ name, attributes, errors, startMin, startMax, limit, cursor })`, `store.getTrace(id)`, `store.findMetrics({ name, attributes })`, `renderTrace(spans)` | XI | reading traces and metrics |
|
||||
| `AppInfo`, `self.open(open, close)` | XI | the data directory, a native (the viewer's store) |
|
||||
| `testPlugin(dir, { kind, api, modules, address, principal, stubs }, ctx)` | XII | one plugin alone |
|
||||
|
|
|
|||
|
|
@ -111,8 +111,8 @@ table{max-width:100%}
|
|||
<section class="cover">
|
||||
<div class="kicker">A tutorial · Chord v2 and its app kit · 2026-11-03</div>
|
||||
<h1>Build It<br>Together</h1>
|
||||
<p class="subtitle">One small app, built a step at a time, from an empty server to two people, a page, a terminal, a theme, three languages and an edit applied while they are connected</p>
|
||||
<div class="cover-stats"><div class="stat"><b>10</b><span>steps, one runnable example</span></div><div class="stat"><b>4</b><span>host kinds one plugin runs on</span></div><div class="stat"><b>1</b><span>worker process per list</span></div><div class="stat"><b>0</b><span>restarts for a new theme, a language or an edit</span></div></div>
|
||||
<p class="subtitle">One small app, built a step at a time, from an empty server to two people, a page, a terminal, a theme, three languages, an edit applied while they are connected, and one click traced across its processes</p>
|
||||
<div class="cover-stats"><div class="stat"><b>11</b><span>steps, one runnable example</span></div><div class="stat"><b>4</b><span>host kinds one plugin runs on</span></div><div class="stat"><b>1</b><span>worker process per list</span></div><div class="stat"><b>0</b><span>restarts for a new theme, a language or an edit</span></div></div>
|
||||
<div class="cover-note"><p>The app is <code>packages/chord/test/examples/61-tutorial/</code>, runnable with the line in its <code>main.ts</code>. Every code block
|
||||
below is quoted from its files, and every console output from what its steps print:
|
||||
<code>test/examples/61-tutorial/tutorial.test.ts</code> fails when a quote or an output drifts. The design is
|
||||
|
|
@ -132,8 +132,9 @@ chapter is about p2, whose code lives outside this repository.</p>
|
|||
<li><a href="#languages">VIII. Languages</a></li>
|
||||
<li><a href="#reload">IX. Changing Code</a></li>
|
||||
<li><a href="#export">X. Moving a List</a></li>
|
||||
<li><a href="#testing">XI. Testing One Plugin</a></li>
|
||||
<li><a href="#p2">XII. Writing a p2 Plugin</a></li>
|
||||
<li><a href="#telemetry">XI. Seeing What Happened</a></li>
|
||||
<li><a href="#testing">XII. Testing One Plugin</a></li>
|
||||
<li><a href="#p2">XIII. Writing a p2 Plugin</a></li>
|
||||
<li><a href="#traps-and-open-questions">Traps and Open Questions</a></li>
|
||||
<li><a href="#the-app-s-files">Appendix A: The App's Files</a></li>
|
||||
<li><a href="#reference">Appendix B: Reference</a></li>
|
||||
|
|
@ -156,7 +157,7 @@ states the rule behind it. The app is one directory, <code>test/examples/61-tuto
|
|||
<pre><code>node --conditions=source --experimental-strip-types test/examples/<span class="tk-n">61</span>-tutorial/main.ts</code></pre>
|
||||
<p>With Node 26.5.1 or later the terminal client of chapter V runs too (its sandbox needs that Node); with an older one,
|
||||
that step says so and the rest runs.</p>
|
||||
<h3 id="nine-things-to-know">Nine things to know</h3>
|
||||
<h3 id="ten-things-to-know">Ten things to know</h3>
|
||||
<ol class="findings">
|
||||
<li><strong>An app is its API, its plugins and a start script.</strong> <code>startServer</code> makes the data directory, the store, the
|
||||
server host, HTTP with sign-in, the workers and the plugin roots; <code>startWorker</code> does a worker's half.</li>
|
||||
|
|
@ -175,8 +176,11 @@ plugins contribute, one plugin (or the entry) composes.</li>
|
|||
server, the workers, the pages and the terminals, in place.</li>
|
||||
<li><strong>Text is rendered per viewer.</strong> A plugin's messages are typed code, a translation is a plugin, each user picks a
|
||||
language, and servers store keys, never sentences.</li>
|
||||
<li><strong>Five traps:</strong> load before use; a reference acts as whoever made it; await what you start; a watch starts at the
|
||||
current value; the plain <code>t</code> has no language of its own.</li>
|
||||
<li><strong>One click is one trace.</strong> Every process records spans to the telemetry store it is given (here one SQLite file
|
||||
in the data directory); carriers in the calls' headers join the page's call, the server's relay and the worker's
|
||||
method into one trace, which <code>TelemetryStore</code> reads back. <code>ctx.trace</code> adds spans of your own.</li>
|
||||
<li><strong>Six traps:</strong> load before use; a reference acts as whoever made it; await what you start; a watch starts at the
|
||||
current value; the plain <code>t</code> has no language of its own; a trace starts where a span starts.</li>
|
||||
</ol>
|
||||
<h3 id="words-used-throughout">Words used throughout</h3>
|
||||
<p>A <strong>host</strong> is one participant of the app, with an <strong>address</strong> if others can reach it (the server, <code>List({ id })</code>) or
|
||||
|
|
@ -188,7 +192,8 @@ are the member sets different principals get. A <strong>factory</strong> declare
|
|||
(values computed from others), <strong>natives</strong> (things opened and closed, such as a file or a database cursor). A <strong>reference</strong> is what asking for an object returns. A <strong>facet</strong> is a
|
||||
plugin's code for one host kind; a <strong>registry</strong> is an extension point plugins <strong>contribute</strong> to. A <strong>message</strong> is a
|
||||
text with a key, a <strong>catalog</strong> a plugin's messages in one language, a <strong>locale</strong> a language tag (<code>de</code>, <code>pt-BR</code>). A <strong>rollout</strong> applies
|
||||
new plugin code to the hosts in order.</p>
|
||||
new plugin code to the hosts in order. A <strong>span</strong> is one timed piece of work a host recorded, a <strong>trace</strong> the spans of
|
||||
one request across hosts, joined by the <strong>carriers</strong> calls carry.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="server"><header class="opener"><div class="numeral">I</div><h2>One Server</h2><p class="lede">The API, the principals and a start script: the smallest app the kit runs, and who may come in.</p></header>
|
||||
|
|
@ -295,7 +300,12 @@ facet kinds (which files a plugin may have), and the principals:</p>
|
|||
tui: { platform: <span class="tk-s">"node"</span> },
|
||||
} <span class="tk-k">as</span> <span class="tk-k">const</span>;</code></pre>
|
||||
<!-- quote: start.ts -->
|
||||
<pre><code><span class="tk-k">export</span> <span class="tk-k">function</span> <span class="tk-f">startTodo</span>(dataDir: string, root: string, context: <span class="tk-t">Context</span>): <span class="tk-t">Promise</span><<span class="tk-t">App</span><<span class="tk-t">TodoPrincipal</span>>> {
|
||||
<pre><code><span class="tk-k">export</span> <span class="tk-k">function</span> <span class="tk-f">startTodo</span>(
|
||||
dataDir: string,
|
||||
root: string,
|
||||
telemetry: <span class="tk-t">TodoTelemetry</span>,
|
||||
context: <span class="tk-t">Context</span>,
|
||||
): <span class="tk-t">Promise</span><<span class="tk-t">App</span><<span class="tk-t">TodoPrincipal</span>>> {
|
||||
<span class="tk-k">return</span> <span class="tk-f">startServer</span><<span class="tk-t">TodoPrincipal</span>>(
|
||||
{
|
||||
name: <span class="tk-s">"todo"</span>,
|
||||
|
|
@ -305,38 +315,47 @@ facet kinds (which files a plugin may have), and the principals:</p>
|
|||
api: API,
|
||||
kinds: KINDS,
|
||||
principal: { server: { kind: <span class="tk-s">"server"</span> }, worker: (address) => ({ kind: <span class="tk-s">"worker"</span>, list: address.key }) },
|
||||
<span class="tk-c">// The server's spans and metrics; each list's worker opens the same file (`worker.ts`).</span>
|
||||
telemetry: telemetry.store,
|
||||
workers: [
|
||||
{
|
||||
kind: v1.<span class="tk-t">List</span>,
|
||||
entry: <span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./worker.ts"</span>, <span class="tk-k">import</span>.meta.url)),
|
||||
execArgv: [<span class="tk-s">"--conditions=source"</span>, <span class="tk-s">"--experimental-strip-types"</span>, <span class="tk-s">"--no-warnings"</span>],
|
||||
env: { TODO_TELEMETRY: <span class="tk-f">telemetryFile</span>(dataDir) },
|
||||
start: () => ({}),
|
||||
<span class="tk-c">// Who may reach a list's worker: everyone signed in, and the server itself.</span>
|
||||
authorize: (principal) => principal.kind !== <span class="tk-s">"worker"</span>,
|
||||
},
|
||||
],
|
||||
web: { entry: <span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./src/web/main.ts"</span>, <span class="tk-k">import</span>.meta.url)), page },
|
||||
<span class="tk-c">// The page, and the owner's trace viewer: the same page, which shows the viewer at `/traces`.</span>
|
||||
web: { entry: <span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./src/web/main.ts"</span>, <span class="tk-k">import</span>.meta.url)), page, paths: [<span class="tk-s">"/"</span>, <span class="tk-s">"/traces"</span>] },
|
||||
tui: { entry: <span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./src/tui/main.ts"</span>, <span class="tk-k">import</span>.meta.url)) },
|
||||
onError: (error) => console.<span class="tk-f">error</span>(<span class="tk-s">"todo:"</span>, error.kind, error.error),
|
||||
},
|
||||
context,
|
||||
);
|
||||
}</code></pre>
|
||||
<p><code>workers</code> is chapter II, <code>web</code> chapter IV, <code>tui</code> chapter V. <code>listen</code> asks for a free port on loopback, as the example
|
||||
does; a real app picks a port. The worker entry is three lines:</p>
|
||||
<p><code>workers</code> is chapter II, <code>web</code> chapter IV (its second path, <code>/traces</code>, chapter XI), <code>tui</code> chapter V, <code>telemetry</code>
|
||||
chapter XI. <code>listen</code> asks for a free port on loopback, as the example does; a real app picks a port. The worker entry
|
||||
is a few lines (its telemetry store is chapter XI's too):</p>
|
||||
<!-- quote: worker.ts -->
|
||||
<pre><code><span class="tk-c">// The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects</span>
|
||||
<span class="tk-c">// to the server and loads the plugins' worker facets.</span>
|
||||
<span class="tk-c">// to the server and loads the plugins' worker facets. The worker records to the app's telemetry store, the file its</span>
|
||||
<span class="tk-c">// server named in the environment: its methods called through the server continue the caller's trace.</span>
|
||||
|
||||
<span class="tk-k">import</span> { startWorker } <span class="tk-k">from</span> <span class="tk-s">"../../../src/node/app/index.ts"</span>;
|
||||
<span class="tk-k">import</span> { openTelemetry } <span class="tk-k">from</span> <span class="tk-s">"./src/telemetry.ts"</span>;
|
||||
<span class="tk-k">import</span> { API, KINDS } <span class="tk-k">from</span> <span class="tk-s">"./start.ts"</span>;
|
||||
|
||||
<span class="tk-f">startWorker</span>({ api: API, kinds: KINDS, onError: (error) => console.<span class="tk-f">error</span>(<span class="tk-s">"list:"</span>, error) });</code></pre>
|
||||
<span class="tk-k">const</span> telemetry = <span class="tk-k">await</span> <span class="tk-f">openTelemetry</span>(process.env.TODO_TELEMETRY!);
|
||||
<span class="tk-f">startWorker</span>({ api: API, kinds: KINDS, telemetry: telemetry.store, onError: (error) => console.<span class="tk-f">error</span>(<span class="tk-s">"list:"</span>, error) });</code></pre>
|
||||
<h3 id="running-it">Running it</h3>
|
||||
<p>Step 1 of <code>main.ts</code> starts the app, tries the page without signing in, then does what a browser does with the owner
|
||||
link:</p>
|
||||
<!-- quote: main.ts -->
|
||||
<pre><code><span class="tk-k">const</span> app = <span class="tk-k">await</span> <span class="tk-f">startTodo</span>(dataDir, root, context);
|
||||
<pre><code><span class="tk-k">const</span> telemetry = <span class="tk-k">await</span> <span class="tk-f">openTelemetry</span>(<span class="tk-f">telemetryFile</span>(dataDir));
|
||||
<span class="tk-k">const</span> app = <span class="tk-k">await</span> <span class="tk-f">startTodo</span>(dataDir, root, telemetry, context);
|
||||
<span class="tk-k">const</span> link = <span class="tk-k">new</span> <span class="tk-f">URL</span>(app.<span class="tk-f">ownerLink</span>());
|
||||
<span class="tk-k">const</span> url = link.origin;
|
||||
console.<span class="tk-f">log</span>(<span class="tk-s">"1. owner link:"</span>, <span class="tk-s">`http://127.0.0.1:<port>${link.pathname}#<token>`</span>);
|
||||
|
|
@ -352,12 +371,14 @@ console.<span class="tk-f">log</span>(<span class="tk-s">" the owner signs in:
|
|||
console.<span class="tk-f">log</span>(<span class="tk-s">" GET / signed in:"</span>, (<span class="tk-k">await</span> <span class="tk-f">fetch</span>(url, { headers: { cookie } })).status);</code></pre>
|
||||
<!-- output: main -->
|
||||
<pre class="plain"><code>1. owner link: http://127.0.0.1:<port>/_chord/owner#<token>
|
||||
data directory: logs, owner-token, plugins, server, workers
|
||||
data directory: logs, owner-token, plugins, server, telemetry.sqlite, telemetry.sqlite-shm, telemetry.sqlite-wal, workers
|
||||
GET / signed out: 401
|
||||
the owner signs in: 200 chord-session=…
|
||||
GET / signed in: 200</code></pre>
|
||||
<p><code>startServer</code> made the data directory: <code>server/</code> (its store, the cookie secret, its files), <code>workers/</code> (one directory
|
||||
per worker), <code>plugins/</code> (the user's plugin root, beside the app's built-in one), <code>logs/</code> and <code>owner-token</code>. The page
|
||||
per worker), <code>plugins/</code> (the user's plugin root, beside the app's built-in one), <code>logs/</code> and <code>owner-token</code>;
|
||||
<code>telemetry.sqlite</code> (with SQLite's <code>-shm</code> and <code>-wal</code> files) is the app's own, opened before the server starts
|
||||
(chapter XI). The page
|
||||
answered 401: HTTP <strong>denies by default</strong>, before any plugin code runs, unless a route says <code>public: true</code>.</p>
|
||||
<p>The <strong>owner link</strong> (<code>/_chord/owner#<token></code>) is how the owner gets in: a single-use token, valid ten minutes, made at
|
||||
each start. It sits in the URL's fragment, which browsers never send, so it never reaches a log; the link's page posts
|
||||
|
|
@ -432,12 +453,14 @@ it on to its parent or to a route (design §3, "Host kinds and addresses&qu
|
|||
kind: v1.<span class="tk-t">List</span>,
|
||||
entry: <span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./worker.ts"</span>, <span class="tk-k">import</span>.meta.url)),
|
||||
execArgv: [<span class="tk-s">"--conditions=source"</span>, <span class="tk-s">"--experimental-strip-types"</span>, <span class="tk-s">"--no-warnings"</span>],
|
||||
env: { TODO_TELEMETRY: <span class="tk-f">telemetryFile</span>(dataDir) },
|
||||
start: () => ({}),
|
||||
<span class="tk-c">// Who may reach a list's worker: everyone signed in, and the server itself.</span>
|
||||
authorize: (principal) => principal.kind !== <span class="tk-s">"worker"</span>,
|
||||
},
|
||||
],</code></pre>
|
||||
<p><code>authorize</code> runs for every request routed to a list before anything starts; the first request for an address starts
|
||||
<p><code>env</code> names the app's telemetry store for the worker (chapter XI). <code>authorize</code> runs for every request routed to a
|
||||
list before anything starts; the first request for an address starts
|
||||
its worker, and a worker idle for five minutes (the default <code>idleMs</code>) stops. <strong>Variants</strong> are the member sets a
|
||||
contract offers: the owner's <code>Todos</code> has <code>clear</code>, a member's does not. The factory picks one per principal.</p>
|
||||
<h3 id="the-worker-facet">The worker facet</h3>
|
||||
|
|
@ -499,8 +522,11 @@ when <code>fn</code> returns, or not at all if it throws. The live query follows
|
|||
else in memory, not even the next todo's number, which it reads in the transaction:</p>
|
||||
<!-- quote: plugins/lists/worker.ts -->
|
||||
<pre><code> add: <span class="tk-k">async</span> ({ text, by }, ctx) => {
|
||||
<span class="tk-c">// A span of its own under the method's: a trace shows the check, and why it refused.</span>
|
||||
<span class="tk-k">await</span> ctx.<span class="tk-f">trace</span>(<span class="tk-s">"todos.validate"</span>, { chars: text.length }, <span class="tk-k">async</span> () => {
|
||||
<span class="tk-c">// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.</span>
|
||||
<span class="tk-k">if</span> (text === <span class="tk-s">""</span>) <span class="tk-k">throw</span> <span class="tk-f">invalid</span>(<span class="tk-s">"args"</span>, <span class="tk-s">"a todo needs text"</span>);
|
||||
});
|
||||
<span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => {
|
||||
<span class="tk-c">// The number after the newest todo's: the store's transactions run one after another.</span>
|
||||
<span class="tk-k">const</span> { items: last } = <span class="tk-k">await</span> tx.<span class="tk-f">page</span>(items, NEWEST, <span class="tk-n">1</span>);
|
||||
|
|
@ -509,7 +535,7 @@ else in memory, not even the next todo's number, which it reads in the trans
|
|||
<span class="tk-k">return</span> <span class="tk-k">null</span>;</code></pre>
|
||||
<p>A todo without text is refused with <code>invalid("args", …)</code>, from the API: the error crosses every link as it is, so a
|
||||
caller on a page or in a terminal gets <code>{ code: "invalid", reason: "args" }</code>, as for arguments that fail the
|
||||
contract's schema.</p>
|
||||
contract's schema. The check runs in a span of its own (<code>ctx.trace</code>), which chapter XI shows in a trace.</p>
|
||||
<h3 id="the-view-per-principal">The view per principal</h3>
|
||||
<p><code>Todos</code> is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:</p>
|
||||
<!-- quote: plugins/lists/worker.ts -->
|
||||
|
|
@ -655,6 +681,8 @@ for them:</p>
|
|||
|
||||
<span class="tk-k">function</span> <span class="tk-t">Page</span>() {
|
||||
<span class="tk-k">const</span> panels = <span class="tk-f">useRegistry</span>(v1web.<span class="tk-t">Panels</span>) ?? [];
|
||||
<span class="tk-c">// The page's one other path (`start.ts` serves the page at `/` and `/traces`).</span>
|
||||
<span class="tk-k">if</span> (location.pathname === <span class="tk-s">"/traces"</span>) <span class="tk-k">return</span> <span class="tk-f">h</span>(<span class="tk-s">"main"</span>, { className: <span class="tk-s">"wide"</span> }, <span class="tk-f">h</span>(<span class="tk-s">"h1"</span>, <span class="tk-k">null</span>, <span class="tk-s">"Todo"</span>), <span class="tk-f">h</span>(<span class="tk-t">TraceViewer</span>));
|
||||
<span class="tk-k">return</span> <span class="tk-f">h</span>(
|
||||
<span class="tk-s">"main"</span>,
|
||||
<span class="tk-k">null</span>,
|
||||
|
|
@ -669,8 +697,9 @@ for them:</p>
|
|||
core: [{ name: <span class="tk-s">"@todo/web"</span>, facet: core }],
|
||||
render: (host) => <span class="tk-f">createRoot</span>(document.<span class="tk-f">getElementById</span>(<span class="tk-s">"root"</span>)!).<span class="tk-f">render</span>(<span class="tk-f">h</span>(<span class="tk-t">HostProvider</span>, { host }, <span class="tk-f">h</span>(<span class="tk-t">Page</span>))),
|
||||
});</code></pre>
|
||||
<p><code>startPage</code> makes the page's host, connects it to <code>/chord</code> (reconnecting after drops), loads the current plugin
|
||||
scripts with the entry's core facets, and keeps them current. <code>HostProvider</code> gives the React tree that host.</p>
|
||||
<p>The path <code>/traces</code> is the owner's trace viewer (chapter XI). <code>startPage</code> makes the page's host, connects it to <code>/chord</code>
|
||||
(reconnecting after drops), loads the current plugin scripts with the entry's core facets, and keeps them current.
|
||||
<code>HostProvider</code> gives the React tree that host.</p>
|
||||
<h3 id="the-web-facet">The web facet</h3>
|
||||
<p><code>plugins/lists/web.ts</code> contributes a panel. Its component holds the list's <code>Todos</code> while it is mounted and re-renders
|
||||
when its items change:</p>
|
||||
|
|
@ -1251,7 +1280,249 @@ replacing a list is a delete, then an import (decision "App kit naming and
|
|||
an index of the app's, if it had one, is the app's.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="testing"><header class="opener"><div class="numeral">XI</div><h2>Testing One Plugin</h2><p class="lede">testPlugin builds a plugin as production does and loads it alone on a host of its own.</p></header>
|
||||
<section class="chapter" id="telemetry"><header class="opener"><div class="numeral">XI</div><h2>Seeing What Happened</h2><p class="lede">One telemetry store for every process, one trace from a click to a commit, a span of your own, and a small viewer for the owner.</p></header>
|
||||
<p class="dropcap">Ada says that adding a todo from her phone feels slow some evenings. Her todo crosses three processes: her page, the
|
||||
server's relay and the list's worker. A log line per process would say what each one did, but not which lines belong
|
||||
to her click, or where the time went. Chord records <strong>telemetry</strong> through pi-telemetry: <strong>spans</strong> (a named, timed
|
||||
piece of work with attributes), <strong>events</strong> on spans, and <strong>metrics</strong> (counters and histograms). Each host has one
|
||||
<strong>adapter</strong>, given to <code>createHost({ telemetry })</code> and NOOP by default, which decides where they go. A <strong>carrier</strong> in
|
||||
each call's header names the caller's span, so the spans of one request, recorded by different processes, form one
|
||||
<strong>trace</strong> (design §29). Mara wants to see them without reading SQL: a small trace viewer would be nice. This chapter
|
||||
records, follows one click, adds a span, reads the store from a script, and ends with the viewer.</p>
|
||||
<h3 id="recording">Recording</h3>
|
||||
<p>The app keeps its telemetry in one SQLite file in the data directory, through Chord's store
|
||||
(<code>@earendil-works/chord/telemetry-sqlite</code>). The app's base code opens it, for any process that asks:</p>
|
||||
<!-- quote: src/telemetry.ts -->
|
||||
<pre><code><span class="tk-c">/** The telemetry store's file in the data directory `dataDir`. */</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">function</span> <span class="tk-f">telemetryFile</span>(dataDir: string): string {
|
||||
<span class="tk-k">return</span> <span class="tk-f">join</span>(dataDir, <span class="tk-s">"telemetry.sqlite"</span>);
|
||||
}
|
||||
|
||||
<span class="tk-c">/** Opens the telemetry store at `file` for this process (its directory made, private, when missing). */</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">async</span> <span class="tk-k">function</span> <span class="tk-f">openTelemetry</span>(file: string): <span class="tk-t">Promise</span><<span class="tk-t">TodoTelemetry</span>> {
|
||||
<span class="tk-c">// Spans carry who called what: the data directory is private before the store's first file appears in it.</span>
|
||||
<span class="tk-k">await</span> <span class="tk-f">mkdir</span>(<span class="tk-f">dirname</span>(file), { recursive: <span class="tk-k">true</span>, mode: 0o700 });
|
||||
<span class="tk-k">const</span> db = <span class="tk-k">await</span> <span class="tk-f">openNodeSqliteDatabase</span>(file);
|
||||
<span class="tk-k">const</span> store = <span class="tk-k">await</span> <span class="tk-f">createSqliteTelemetry</span>(db);
|
||||
<span class="tk-k">return</span> {
|
||||
store,
|
||||
close: <span class="tk-k">async</span> () => {
|
||||
<span class="tk-k">await</span> store.<span class="tk-f">close</span>();
|
||||
<span class="tk-k">await</span> db.<span class="tk-f">close</span>();
|
||||
},
|
||||
};
|
||||
}</code></pre>
|
||||
<p><code>createSqliteTelemetry</code> returns a <strong>recorder</strong>: an adapter that keeps each settled span in memory and writes them in
|
||||
one batch a second, one row per span with its events and links inline; metrics are summed per series and 10 seconds;
|
||||
rows older than seven days are deleted in the same batch, and when 10,000 spans wait, new ones are dropped and counted.
|
||||
Recording never runs SQL. The same object is the store's <strong>read interface</strong>, <code>TelemetryStore</code> (below).</p>
|
||||
<p>Step 1 opened the store before the server (chapter I), and <code>startTodo</code> gives it to <code>startServer</code>, which passes it to the
|
||||
server's host and its HTTP component:</p>
|
||||
<!-- quote: start.ts -->
|
||||
<pre><code> <span class="tk-c">// The server's spans and metrics; each list's worker opens the same file (`worker.ts`).</span>
|
||||
telemetry: telemetry.store,</code></pre>
|
||||
<p>The kit carries no spans between processes: each process records to the adapter it was given (slice 9, §5). So each
|
||||
list's worker opens the same file with a recorder of its own; the server names the file in the worker's environment:</p>
|
||||
<!-- quote: start.ts -->
|
||||
<pre><code> env: { TODO_TELEMETRY: <span class="tk-f">telemetryFile</span>(dataDir) },</code></pre>
|
||||
<!-- quote: worker.ts -->
|
||||
<pre><code><span class="tk-k">const</span> telemetry = <span class="tk-k">await</span> <span class="tk-f">openTelemetry</span>(process.env.TODO_TELEMETRY!);
|
||||
<span class="tk-f">startWorker</span>({ api: API, kinds: KINDS, telemetry: telemetry.store, onError: (error) => console.<span class="tk-f">error</span>(<span class="tk-s">"list:"</span>, error) });</code></pre>
|
||||
<p>One file for every process is the app's choice. SQLite in WAL mode lets several processes write it, each waiting for
|
||||
the others' locks (up to 5 seconds), and every span id is global, so rows of different processes never collide. An app
|
||||
with a collector would give each process an adapter that exports there instead; nothing else changes.</p>
|
||||
<h3 id="one-trace-across-three-hosts">One trace across three hosts</h3>
|
||||
<p>A page in a browser cannot record yet: <code>startPage</code> takes no telemetry adapter. Step 11 makes the page itself, as Ada's
|
||||
client was made in chapter VII: a client host of this process with a recorder of its own on the same file, signed in
|
||||
with the owner's cookie from step 1. A click is one span of the page, and the call runs under it:</p>
|
||||
<!-- quote: main.ts -->
|
||||
<pre><code><span class="tk-k">const</span> pageTelemetry = <span class="tk-k">await</span> <span class="tk-f">openTelemetry</span>(<span class="tk-f">telemetryFile</span>(dataDir));
|
||||
<span class="tk-k">const</span> page = <span class="tk-f">createHost</span><<span class="tk-t">TodoPrincipal</span>>({
|
||||
telemetry: pageTelemetry.store,
|
||||
onError: (error) => console.<span class="tk-f">error</span>(<span class="tk-s">"page:"</span>, error.kind),
|
||||
});
|
||||
<span class="tk-k">await</span> page.<span class="tk-f">load</span>([], context);
|
||||
<span class="tk-k">const</span> pageSocket = <span class="tk-k">new</span> <span class="tk-t">WebSocket</span>(<span class="tk-s">`${url.replace(/^http/, "ws")}/chord`</span>, { headers: { cookie } });
|
||||
page.<span class="tk-f">connect</span>(<span class="tk-f">webSocketLink</span>(pageSocket <span class="tk-k">as</span> <span class="tk-k">unknown</span> <span class="tk-k">as</span> <span class="tk-t">WebSocketLike</span>), { principal: { kind: <span class="tk-s">"server"</span> } }, context);
|
||||
<span class="tk-k">const</span> pageTodos = <span class="tk-k">await</span> page.<span class="tk-f">get</span>(<span class="tk-t">Todos</span>, { list: <span class="tk-s">"groceries"</span> }, context);
|
||||
<span class="tk-k">const</span> click = (text: string) =>
|
||||
pageTelemetry.store.<span class="tk-f">startSpan</span>({ name: <span class="tk-s">"page.add"</span> }, (span) => pageTodos.<span class="tk-f">add</span>(text, <span class="tk-f">withTelemetry</span>(span, context)));
|
||||
<span class="tk-k">await</span> <span class="tk-f">click</span>(<span class="tk-s">"honey"</span>);
|
||||
<span class="tk-k">await</span> <span class="tk-f">click</span>(<span class="tk-s">" "</span>).<span class="tk-k">catch</span>(() => <span class="tk-s">"refused"</span>);</code></pre>
|
||||
<p><code>startSpan</code> runs the callback in a new root span; <code>withTelemetry(span, context)</code> makes it the context's span, so the
|
||||
call of <code>add</code> starts its <code>chord.call</code> under it and puts that span's carrier in the call's header. Each recorder writes
|
||||
once a second, so the step reads the store until the worker's spans are there, takes the older of the two traces whose
|
||||
first span is <code>page.add</code> and prints it with pi-telemetry's <code>renderTrace</code> (main.ts masks the lengths of time, which
|
||||
differ at every run):</p>
|
||||
<!-- output: main -->
|
||||
<pre class="plain"><code>11. adding honey from the page:
|
||||
page.add
|
||||
chord.call type="todo.todos" member="add" object="todo.todos(groceries)" address.kind="list" outcome="ok"
|
||||
chord.relay type="todo.todos" member="#0" upstream.kind="list" outcome="ok"
|
||||
chord.method type="todo.todos" member="add" object="todo.todos(groceries)" principal.kind="owner" outcome="ok"
|
||||
chord.method type="todo.store" member="add" object="todo.store" principal.kind="worker" outcome="ok"
|
||||
· chord.commit documents=0 ops=0 duration=<ms>
|
||||
todos.validate chars=5</code></pre>
|
||||
<p>Three processes recorded these six spans. The page recorded <code>page.add</code> and <code>chord.call</code>, the consumer's call until its
|
||||
reply. The server recorded <code>chord.relay</code>: it forwarded the call to the list's worker without knowing member names
|
||||
(<code>#0</code> is the method's index). The worker recorded the rest: <code>chord.method</code> for the owner's <code>Todos</code> view, then the
|
||||
view's call of the store, which is a method span too, by <code>worker</code>, because the store acts as its host (chapter II).
|
||||
The commit is an event on the span its transaction ran under, and the renderer prints a span's events before its
|
||||
children. Its counts are documents and their ops; a commit of table rows counts neither, so this one says 0 and 0.</p>
|
||||
<figure class="fig anim"><svg viewBox="0 0 720 326" width="100%" role="img" aria-label="One click, one trace: each process starts its spans with its own recorder, and the carrier in each call's header names the parent span on the other side." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f5m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f5a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f5b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><line x1="130.7" y1="52" x2="130.7" y2="318" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="45.7" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#c9bfae"/><text x="130.7" y="27" font-size="11" fill="#2a2723" text-anchor="middle">page</text><text x="130.7" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">recorder 1</text><line x1="360" y1="52" x2="360" y2="318" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="275" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#c9bfae"/><text x="360" y="27" font-size="11" fill="#2a2723" text-anchor="middle">server</text><text x="360" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">recorder 2</text><line x1="589.3" y1="52" x2="589.3" y2="318" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="504.3" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#c9bfae"/><text x="589.3" y="27" font-size="11" fill="#2a2723" text-anchor="middle">list|groceries</text><text x="589.3" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">recorder 3</text><g class="step edge" style="--i:1"><path d="M130.7 72 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f5m)"/><text x="172.7" y="82" font-size="10" fill="#2a2723" text-anchor="start">page.add, chord.call</text></g><g class="step edge" style="--i:2"><line x1="130.7" y1="118" x2="357" y2="118" stroke="#b4472f" marker-end="url(#f5a)"/><text x="245.3" y="112" font-size="10" fill="#b4472f" text-anchor="middle">call add, carrier of chord.call</text></g><g class="step edge" style="--i:3"><path d="M360 144 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f5m)"/><text x="402" y="154" font-size="10" fill="#2a2723" text-anchor="start">chord.relay</text></g><g class="step edge" style="--i:4"><line x1="360" y1="190" x2="586.3" y2="190" stroke="#b4472f" marker-end="url(#f5a)"/><text x="474.7" y="184" font-size="10" fill="#b4472f" text-anchor="middle">call #0, carrier of chord.relay</text></g><g class="step edge" style="--i:5"><path d="M589.3 216 h-34 v14 h32" fill="none" stroke="#7b7468" marker-end="url(#f5m)"/><text x="547.3" y="226" font-size="10" fill="#2a2723" text-anchor="end">chord.method ×2, todos.validate, commit</text></g><g class="step edge" style="--i:6"><line x1="589.3" y1="262" x2="363" y2="262" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f5m)"/><text x="474.7" y="256" font-size="10" fill="#2a2723" text-anchor="middle">reply</text></g><g class="step edge" style="--i:7"><line x1="360" y1="294" x2="133.7" y2="294" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f5m)"/><text x="245.3" y="288" font-size="10" fill="#2a2723" text-anchor="middle">reply</text></g></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 5.</span> One click, one trace: each process starts its spans with its own recorder, and the carrier in each call's header names the parent span on the other side.</figcaption></figure>
|
||||
<p>The carrier is a field of the <code>call</code> and <code>hold</code> headers, at most 8 keys of at most 256 characters each. The receiver
|
||||
reads it with its own adapter; an absent or malformed one gives a new root, never an error. With the NOOP adapter
|
||||
nothing is injected and no span starts: a span site costs one check (§30).</p>
|
||||
<p>The trace holds the click and nothing else. Work that serves many requests is its own trace, <strong>linked</strong> to the span that
|
||||
first needed it: a worker's start (<code>chord.resolve</code>), an object's activation (<code>chord.activate</code>). A link is a reference to
|
||||
a span of another trace; the renderer prints it and never follows it.</p>
|
||||
<h3 id="trap-a-trace-starts-where-a-span-starts">Trap: a trace starts where a span starts</h3>
|
||||
<p>The page took its reference to <code>Todos</code> before the click, under <code>BACKGROUND_CONTEXT</code>, and that hold is in no trace of
|
||||
the page: <strong>a context without a recording span records nothing.</strong> A host gives such work no span of its own, so its
|
||||
calls start no <code>chord.call</code> and send no carrier, and the provider's <code>chord.method</code> becomes a root of its own trace
|
||||
(the server's relay of the page's hold is one: <code>chord.relay</code> with <code>member="hold"</code>). So were the server's calls in step 2:
|
||||
on the worker each is a root <code>chord.method</code>. Start a span where the work begins, a click handler or a job, and pass it
|
||||
down with <code>withTelemetry</code>. The kit's HTTP component does this for every request: a <code>chord.http</code> span (method, route
|
||||
pattern, status), with the route's <code>ctx</code> under it.</p>
|
||||
<h3 id="a-span-of-your-own">A span of your own</h3>
|
||||
<p>The store's <code>add</code> checks the text in a span of its own:</p>
|
||||
<!-- quote: plugins/lists/worker.ts -->
|
||||
<pre><code> <span class="tk-k">await</span> ctx.<span class="tk-f">trace</span>(<span class="tk-s">"todos.validate"</span>, { chars: text.length }, <span class="tk-k">async</span> () => {
|
||||
<span class="tk-c">// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.</span>
|
||||
<span class="tk-k">if</span> (text === <span class="tk-s">""</span>) <span class="tk-k">throw</span> <span class="tk-f">invalid</span>(<span class="tk-s">"args"</span>, <span class="tk-s">"a todo needs text"</span>);
|
||||
});</code></pre>
|
||||
<p><code>ctx.trace(name, attributes, fn)</code> starts a child of the method's span and gives <code>fn</code> a child <code>ctx</code> with the same
|
||||
principal and <code>get</code>, so calls made inside are parented under it. It is the last line of the trace above:
|
||||
<code>todos.validate chars=5</code>. Its attributes are small, flat values; the todo's text is data and stays out of telemetry,
|
||||
its length is enough. When <code>fn</code> throws, the span fails and the error passes through as it was: the caller still gets
|
||||
<code>invalid/args</code>. A check this small does not need a span of its own; a lookup, a model request or a file read does.</p>
|
||||
<h3 id="reading">Reading</h3>
|
||||
<p>The store is read through <code>TelemetryStore</code>, pi-telemetry's read interface modeled on Jaeger's: <code>findTraces</code> (complete
|
||||
traces, newest first, filtered by their first span's name, attributes, errors or a time window, paged by a cursor),
|
||||
<code>getTrace(id)</code> and <code>findMetrics</code> (a metric's rows, one per series and 10 seconds, with count, sum, min and max).
|
||||
<code>trace.ts</code> is a script for a data directory, the app running or not:</p>
|
||||
<!-- quote: trace.ts -->
|
||||
<pre><code> <span class="tk-k">if</span> (id !== <span class="tk-k">undefined</span>) {
|
||||
<span class="tk-k">const</span> trace = <span class="tk-k">await</span> telemetry.store.<span class="tk-f">getTrace</span>(id);
|
||||
console.<span class="tk-f">log</span>(trace === <span class="tk-k">undefined</span> ? <span class="tk-s">`no trace ${id}`</span> : <span class="tk-f">renderTrace</span>(trace.spans));
|
||||
} <span class="tk-k">else</span> {
|
||||
<span class="tk-c">// Complete traces, newest first; a trace's first span (its root, by start time) names it.</span>
|
||||
<span class="tk-k">const</span> { traces } = <span class="tk-k">await</span> telemetry.store.<span class="tk-f">findTraces</span>({ name: values.name, limit: <span class="tk-n">10</span> });
|
||||
<span class="tk-k">for</span> (<span class="tk-k">const</span> { traceId, spans } <span class="tk-k">of</span> traces) {
|
||||
<span class="tk-k">const</span> start = <span class="tk-t">Math</span>.<span class="tk-f">min</span>(...spans.<span class="tk-f">map</span>((span) => span.startedMs));
|
||||
<span class="tk-k">const</span> length = <span class="tk-t">Math</span>.<span class="tk-f">max</span>(...spans.<span class="tk-f">map</span>((span) => span.endedMs)) - start;
|
||||
<span class="tk-k">const</span> failed = spans.<span class="tk-f">some</span>((span) => span.status.status === <span class="tk-s">"error"</span>) ? <span class="tk-s">" error"</span> : <span class="tk-s">""</span>;
|
||||
console.<span class="tk-f">log</span>(<span class="tk-s">`${traceId} ${spans[0]!.name} ${spans.length} spans ${length.toFixed(2)} ms${failed}`</span>);
|
||||
}
|
||||
<span class="tk-c">// Metrics are stored per series and 10 s: a series is one set of attributes, here the direction.</span>
|
||||
<span class="tk-k">for</span> (<span class="tk-k">const</span> direction <span class="tk-k">of</span> [<span class="tk-s">"sent"</span>, <span class="tk-s">"received"</span>]) {
|
||||
<span class="tk-k">const</span> rows = <span class="tk-k">await</span> telemetry.store.<span class="tk-f">findMetrics</span>({ name: <span class="tk-s">"chord.link.bytes"</span>, attributes: { direction } });
|
||||
<span class="tk-k">const</span> bytes = rows.<span class="tk-f">reduce</span>((sum, row) => sum + row.sum, <span class="tk-n">0</span>);
|
||||
<span class="tk-k">const</span> messages = rows.<span class="tk-f">reduce</span>((sum, row) => sum + row.count, <span class="tk-n">0</span>);
|
||||
console.<span class="tk-f">log</span>(<span class="tk-s">`chord.link.bytes ${direction}: ${bytes} bytes in ${messages} messages`</span>);
|
||||
}
|
||||
}</code></pre>
|
||||
<p>Step 11 runs it twice from a process of its own: the newest <code>page.add</code> traces, then the refused one by its id (ids,
|
||||
lengths and counts masked):</p>
|
||||
<!-- output: main -->
|
||||
<pre class="plain"><code> trace.ts <data directory> --name page.add:
|
||||
<id> page.add 6 spans <ms> error
|
||||
<id> page.add 6 spans <ms>
|
||||
chord.link.bytes sent: <n> bytes in <n> messages
|
||||
chord.link.bytes received: <n> bytes in <n> messages
|
||||
trace.ts <data directory> <the refused one's id>:
|
||||
page.add error ChordError: a todo needs text
|
||||
chord.call type="todo.todos" member="add" object="todo.todos(groceries)" address.kind="list" outcome="error" error call_failed: a todo needs text
|
||||
chord.relay type="todo.todos" member="#0" upstream.kind="list" outcome="error" error relay_failed: a todo needs text
|
||||
chord.method type="todo.todos" member="add" object="todo.todos(groceries)" principal.kind="owner" outcome="error" error method_failed: a todo needs text
|
||||
chord.method type="todo.store" member="add" object="todo.store" principal.kind="worker" outcome="error" error method_failed: a todo needs text
|
||||
todos.validate chars=0 error trace_failed: a todo needs text</code></pre>
|
||||
<p>A trace's id is its root span's. The refused add failed on every span of its path, each with a fixed category
|
||||
(<code>call_failed</code>, <code>relay_failed</code>, <code>method_failed</code>, <code>trace_failed</code>) and the message cut at 500 characters; a cancelled call
|
||||
is not an error. It has no commit: nothing was written. <code>chord.link.bytes</code> counts the encoded messages of every host's
|
||||
links, each message once where it was sent and once where it was received. A metric carries no ids, only attributes
|
||||
with few values (<code>direction</code>), so its series stay few. A running recorder writes a metric's current 10 seconds only
|
||||
once they are over, or when it closes: <code>trace.ts</code> sees the page's bytes, whose recorder closed, and the server's and
|
||||
workers' of the intervals already over. The script is a recorder too, which records nothing; Chord ships only the
|
||||
store, so reads go through a recorder of the file.</p>
|
||||
<h3 id="a-trace-viewer">A trace viewer</h3>
|
||||
<p>Two halves make the viewer: routes on the server that read the store, and a page that shows what they answer. Server
|
||||
code of an app is either the start script or a plugin, and routes come from plugins (chapter III), so the routes are a
|
||||
small plugin, <code>plugins/traces/</code>. Its server facet serves one host-lifetime object, which opens the store's file as a
|
||||
native and contributes the routes while it is live:</p>
|
||||
<!-- quote: plugins/traces/server.ts -->
|
||||
<pre><code> host.<span class="tk-f">serve</span>(<span class="tk-t">Traces</span>, (_params, self) => {
|
||||
<span class="tk-k">const</span> info = self.<span class="tk-f">get</span>(<span class="tk-t">AppInfo</span>, {});
|
||||
<span class="tk-c">// The server records to the same file with its own recorder; this one only reads.</span>
|
||||
<span class="tk-k">const</span> telemetry = self.<span class="tk-f">open</span>(
|
||||
<span class="tk-k">async</span> () => <span class="tk-f">openTelemetry</span>(<span class="tk-f">telemetryFile</span>(info.dataDir.value)),
|
||||
(opened) => opened.<span class="tk-f">close</span>(),
|
||||
);
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Http</span>, {
|
||||
routes: {
|
||||
<span class="tk-s">"GET /api/traces"</span>: <span class="tk-k">async</span> (_request, ctx) => {
|
||||
<span class="tk-f">ownerOnly</span>(ctx);
|
||||
<span class="tk-k">const</span> { traces } = <span class="tk-k">await</span> telemetry.value.store.<span class="tk-f">findTraces</span>({ limit: RECENT });
|
||||
<span class="tk-k">return</span> { kind: <span class="tk-s">"json"</span>, value: traces.<span class="tk-f">map</span>(({ traceId, spans }) => <span class="tk-f">summaryOf</span>(traceId, spans)) };
|
||||
},
|
||||
<span class="tk-s">"GET /api/traces/:id"</span>: <span class="tk-k">async</span> (request, ctx) => {
|
||||
<span class="tk-f">ownerOnly</span>(ctx);
|
||||
<span class="tk-k">const</span> trace = <span class="tk-k">await</span> telemetry.value.store.<span class="tk-f">getTrace</span>(request.params.id!);
|
||||
<span class="tk-k">if</span> (trace === <span class="tk-k">undefined</span>) <span class="tk-k">return</span> { kind: <span class="tk-s">"error"</span>, status: <span class="tk-n">404</span> };
|
||||
<span class="tk-k">return</span> { kind: <span class="tk-s">"json"</span>, value: { id: trace.traceId, spans: <span class="tk-f">rowsOf</span>(trace.spans) } };
|
||||
},
|
||||
},
|
||||
});
|
||||
<span class="tk-k">return</span> {};
|
||||
});</code></pre>
|
||||
<p>The kit gives a plugin the server's process values as objects: <code>AppInfo</code> names the data directory, and the API's server
|
||||
module re-exports it with the app's <code>openTelemetry</code>. <code>self.open</code> declares a native, opened once <code>AppInfo</code> is live and
|
||||
closed when the object ends; a reload of the plugin restarts the object and opens the file anew. An object may
|
||||
contribute when it lives as long as its host (<code>local</code>, empty params; decision R21). Spans name who called what, so
|
||||
only the owner reads them:</p>
|
||||
<!-- quote: plugins/traces/server.ts -->
|
||||
<pre><code><span class="tk-k">function</span> <span class="tk-f">ownerOnly</span>(ctx: <span class="tk-t">Ctx</span>): <span class="tk-k">void</span> {
|
||||
<span class="tk-k">if</span> (ctx.principal?.kind !== <span class="tk-s">"owner"</span>) <span class="tk-k">throw</span> <span class="tk-f">forbidden</span>(<span class="tk-s">"the traces are the owner's"</span>);
|
||||
}</code></pre>
|
||||
<p>A route's principal is <code>undefined</code> only on a public route, which these are not; a signed-in member gets 403. <code>rowsOf</code>
|
||||
orders a trace's spans as a tree, parents before their children, and turns attributes, events and links into text. The
|
||||
spans name no host; the SQLite store's span ids begin with the id of the recorder, one per process, and the viewer
|
||||
calls each recorder a host:</p>
|
||||
<!-- quote: plugins/traces/server.ts -->
|
||||
<pre><code><span class="tk-k">function</span> <span class="tk-f">processOf</span>(span: <span class="tk-t">StoredSpan</span>): string {
|
||||
<span class="tk-k">return</span> span.id.<span class="tk-f">slice</span>(<span class="tk-n">0</span>, span.id.<span class="tk-f">lastIndexOf</span>(<span class="tk-s">":"</span>));
|
||||
}</code></pre>
|
||||
<p>That is a detail of this store, not of the read interface (see the epilogue). The page is the app's own page at a
|
||||
second path: <code>start.ts</code> serves it at <code>/</code> and <code>/traces</code> (chapter I), and the web entry picks the viewer by the path:</p>
|
||||
<!-- quote: src/web/main.ts -->
|
||||
<pre><code> <span class="tk-c">// The page's one other path (`start.ts` serves the page at `/` and `/traces`).</span>
|
||||
<span class="tk-k">if</span> (location.pathname === <span class="tk-s">"/traces"</span>) <span class="tk-k">return</span> <span class="tk-f">h</span>(<span class="tk-s">"main"</span>, { className: <span class="tk-s">"wide"</span> }, <span class="tk-f">h</span>(<span class="tk-s">"h1"</span>, <span class="tk-k">null</span>, <span class="tk-s">"Todo"</span>), <span class="tk-f">h</span>(<span class="tk-t">TraceViewer</span>));</code></pre>
|
||||
<p><code>TraceViewer</code> (<code>src/web/traces.ts</code>) fetches the list and the trace chosen in the URL's fragment, and draws each span
|
||||
with its host, its length as a bar on the trace's time line, its attributes and events, a failed span in the theme's
|
||||
error colour, and each link as the ids it refers to, never fetched. The bars are positioned with inline styles, which
|
||||
the page's CSP allows only because React sets them through the DOM:</p>
|
||||
<!-- quote: src/web/traces.ts -->
|
||||
<pre><code> <span class="tk-k">const</span> bar = {
|
||||
left: <span class="tk-s">`${(row.offsetMs / total) * 100}%`</span>,
|
||||
width: <span class="tk-s">`${Math.max((row.durationMs / total) * 100, 0.5)}%`</span>,
|
||||
};</code></pre>
|
||||
<p><code>screenshots.ts</code> adds honey from a recording page and refuses a blank todo, as step 11 does, then opens the viewer on
|
||||
honey's trace:</p>
|
||||
<!-- quote: screenshots.ts -->
|
||||
<pre><code> <span class="tk-k">await</span> browser.<span class="tk-f">open</span>(<span class="tk-s">`${origin}/traces#${encodeURIComponent(trace.traceId)}`</span>);
|
||||
<span class="tk-k">await</span> browser.<span class="tk-f">wait</span>(<span class="tk-s">"document.querySelectorAll('.traces-tree li').length === 6"</span>);
|
||||
<span class="tk-k">await</span> <span class="tk-f">shoot</span>(<span class="tk-s">"traces.png"</span>);</code></pre>
|
||||
<!-- output: screenshots -->
|
||||
<pre class="plain"><code>wrote docs/tutorial/screens/traces.png
|
||||
the viewer shows page.add: 6 spans on 3 hosts</code></pre>
|
||||
<figure class="fig wide"><p><img src="screens/traces.png" alt="The trace viewer"></p>
|
||||
<figcaption><span class="fignum">Figure 6.</span> The owner's trace viewer at <code>/traces</code>, in the Harbor theme: the newest traces (the refused add marked in red, the rest the app's own work: relays, activations, HTTP requests), and honey's trace as a tree across the page, the server and the worker. Starts are stored in whole milliseconds, so the bars of spans this short shift by up to one.</figcaption></figure>
|
||||
<p>The list is mostly the app's own work: each HTTP request, relayed hold and activation is a trace of its own, as the
|
||||
trap above says. A real viewer would filter by name (<code>findTraces({ name })</code>) or by error (<code>errors: true</code>), both of
|
||||
which the read interface has; this one is about 270 lines of TypeScript in two files and 20 of CSS, and no Chord API was added for it.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="testing"><header class="opener"><div class="numeral">XII</div><h2>Testing One Plugin</h2><p class="lede">testPlugin builds a plugin as production does and loads it alone on a host of its own.</p></header>
|
||||
<p class="dropcap">The steps above test the whole app. Most rules are checked faster on one plugin. <code>testPlugin</code> builds the plugin as
|
||||
production builds it (the same esbuild call, the API's imports checked), evaluates it with the app's real API module and
|
||||
loads it on a new host with an in-memory store, here at a list's address so it may serve <code>Todos</code>:</p>
|
||||
|
|
@ -1306,7 +1577,7 @@ the refusal of chapter II:</p>
|
|||
<span class="tk-f">expect</span>(refused).<span class="tk-f">toMatchObject</span>({ code: <span class="tk-s">"invalid"</span>, reason: <span class="tk-s">"args"</span> });</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="p2"><header class="opener"><div class="numeral">XII</div><h2>Writing a p2 Plugin</h2><p class="lede">What is different in p2, and one small plugin: a tool for the agent and a chip for the people.</p></header>
|
||||
<section class="chapter" id="p2"><header class="opener"><div class="numeral">XIII</div><h2>Writing a p2 Plugin</h2><p class="lede">What is different in p2, and one small plugin: a tool for the agent and a chip for the people.</p></header>
|
||||
<p class="dropcap">p2, the coding agent's server with its web page and terminal, runs on Chord v2 but not on the app kit. It is older than
|
||||
the kit and keeps its own decisions: its own data directory and SQLite files, its own sign-in, devices and sharing,
|
||||
its own page and terminal bundles. It uses Chord's components directly: <code>createHost</code> with p2's principals,
|
||||
|
|
@ -1449,12 +1720,14 @@ reload. The trap of chapter II applies with p2's names: a server method that
|
|||
server runs in a root of the server's own (<code>serverOf(self)().root</code>), not through its caller's <code>ctx.get</code>.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter epilogue" id="traps-and-open-questions"><header class="opener"><div class="numeral">∴</div><h2>Traps and Open Questions</h2><p class="lede">The five traps once more, and what in Chord v2 was hard to explain while writing this.</p></header>
|
||||
<section class="chapter epilogue" id="traps-and-open-questions"><header class="opener"><div class="numeral">∴</div><h2>Traps and Open Questions</h2><p class="lede">The six traps once more, and what in Chord v2 was hard to explain while writing this.</p></header>
|
||||
<p class="dropcap"><strong>The traps.</strong> Load before use: a host binds nothing before <code>load</code>; a client with nothing to load calls <code>load([])</code>. A
|
||||
reference acts as whoever made it: <code>ctx.get</code> as the caller, <code>host.get</code> and a local object's <code>self.get</code> as the host.
|
||||
Await what you start: a write started with a context that ends is cancelled and reported <code>transaction_cancelled</code>. A
|
||||
watch starts at the current value: read <code>handle.value</code>, then <code>start</code>; the listener gets changes only. The plain <code>t</code>
|
||||
has no language of its own: components and screens render with <code>useMessages(t)</code> and <code>scope.messages(t)</code>.</p>
|
||||
has no language of its own: components and screens render with <code>useMessages(t)</code> and <code>scope.messages(t)</code>. A trace
|
||||
starts where a span starts: work under a context without a recording span records nothing, and its provider's span is
|
||||
a root of its own.</p>
|
||||
<p><strong>Found while writing the example, and fixed in Chord since.</strong> Integer table keys were typed <code>string</code> (a leftover
|
||||
of the switch from TypeBox), so <code>tx.update(items, { id }, ...)</code> with a number failed <code>tsc</code>; the store now uses it. A
|
||||
reloaded web plugin lost its own React state (now <code>usePageState</code>,
|
||||
|
|
@ -1462,6 +1735,14 @@ chapter IX), a plugin's <code>contract.ts</code> could not be imported outsi
|
|||
chapter I), plugins could not throw Chord's <code>invalid</code> (Chord exports <code>invalid</code> and <code>forbidden</code>, chapter II), and a
|
||||
live window over a table needed a one-time read in <code>self.open</code>, which made the store restart at every reload of its
|
||||
plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX).</p>
|
||||
<p><strong>Found while writing the telemetry chapter, still open.</strong> A page cannot record: <code>startPage</code> takes no telemetry
|
||||
adapter, so chapter XI's page is a client host in Node, and a browser's click starts its trace at the server's relay.
|
||||
<code>chord.commit</code> counts documents and their ops only, so a commit that wrote table rows reports <code>documents=0 ops=0</code>.
|
||||
Spans name no host, and the viewer tells processes apart by the SQLite store's id format (<code><recorder>:<n></code>), which
|
||||
the read interface does not promise. The store keeps a span's start in whole milliseconds (<code>Date.now()</code>) and its
|
||||
length in fractions (<code>performance.now()</code>), so on a trace of a few milliseconds the viewer's bars shift by up to one. And
|
||||
reading needs a recorder: <code>chord/telemetry-sqlite</code> exports no read-only
|
||||
store, so a script or a viewer opens a recorder that records nothing.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter appendix" id="the-app-s-files"><header class="opener"><div class="numeral">A</div><h2>The App's Files</h2></header>
|
||||
|
|
@ -1491,7 +1772,7 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX
|
|||
<tr>
|
||||
<td><code>src/api/v1/server.ts</code></td>
|
||||
<td>base, server</td>
|
||||
<td><code>Http</code>, the ephemeral <code>login</code></td>
|
||||
<td><code>Http</code>, the ephemeral <code>login</code>, <code>AppInfo</code>, <code>openTelemetry</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>src/api/v1/web.ts</code></td>
|
||||
|
|
@ -1506,12 +1787,22 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX
|
|||
<tr>
|
||||
<td><code>start.ts</code>, <code>worker.ts</code></td>
|
||||
<td>base</td>
|
||||
<td><code>startServer</code>, <code>startWorker</code></td>
|
||||
<td><code>startServer</code>, <code>startWorker</code>, each with the telemetry store</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>src/telemetry.ts</code></td>
|
||||
<td>base</td>
|
||||
<td>the telemetry store's file in the data directory, <code>openTelemetry</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>src/web/main.ts</code>, <code>src/web/style.css</code></td>
|
||||
<td>base, web entry</td>
|
||||
<td><code>startPage</code>, the panels</td>
|
||||
<td><code>startPage</code>, the panels, the viewer at <code>/traces</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>src/web/traces.ts</code></td>
|
||||
<td>base, web entry</td>
|
||||
<td>the owner's trace viewer</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>src/tui/main.ts</code></td>
|
||||
|
|
@ -1534,6 +1825,11 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX
|
|||
<td>the kit's ephemeral login</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>plugins/traces/</code></td>
|
||||
<td>plugin</td>
|
||||
<td>the trace viewer's routes over the telemetry store (server), the owner's only</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>later/harbor/</code></td>
|
||||
<td>plugin, added in step 6</td>
|
||||
<td>the Harbor theme for pages and terminals</td>
|
||||
|
|
@ -1543,6 +1839,11 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX
|
|||
<td>example</td>
|
||||
<td>the steps, the screenshots, the checks</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>trace.ts</code></td>
|
||||
<td>example</td>
|
||||
<td>a data directory's newest traces, one trace, a metric</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
|
|
@ -1646,8 +1947,28 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX
|
|||
<td>moving a worker's data</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>testPlugin(dir, { kind, api, modules, address, principal, stubs }, ctx)</code></td>
|
||||
<td><code>createSqliteTelemetry(db)</code>, <code>startServer({ telemetry })</code>, <code>startWorker({ telemetry })</code>, <code>createHost({ telemetry })</code></td>
|
||||
<td>XI</td>
|
||||
<td>recording to the telemetry store</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>adapter.startSpan({ name }, fn)</code>, <code>withTelemetry(span, ctx)</code>, <code>ctx.trace(name, attributes, fn)</code></td>
|
||||
<td>XI</td>
|
||||
<td>spans of your own</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>store.findTraces({ name, attributes, errors, startMin, startMax, limit, cursor })</code>, <code>store.getTrace(id)</code>, <code>store.findMetrics({ name, attributes })</code>, <code>renderTrace(spans)</code></td>
|
||||
<td>XI</td>
|
||||
<td>reading traces and metrics</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>AppInfo</code>, <code>self.open(open, close)</code></td>
|
||||
<td>XI</td>
|
||||
<td>the data directory, a native (the viewer's store)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>testPlugin(dir, { kind, api, modules, address, principal, stubs }, ctx)</code></td>
|
||||
<td>XII</td>
|
||||
<td>one plugin alone</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
|
|
@ -1655,5 +1976,51 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX
|
|||
|
||||
<p class="colophon">Together · first edition · 2026-11-03 · Chord v2 slices 1-13, the app kit, example 61</p>
|
||||
</div></div>
|
||||
|
||||
<script>(function activate(root) {
|
||||
const replay = (f) => {
|
||||
f.classList.remove("play");
|
||||
void f.getBoundingClientRect();
|
||||
f.classList.add("play");
|
||||
};
|
||||
const io = new IntersectionObserver(
|
||||
(es) => {
|
||||
for (const e of es)
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add("play");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.3 },
|
||||
);
|
||||
root.querySelectorAll(".anim:not(.armed)").forEach((f) => {
|
||||
f.classList.add("armed");
|
||||
io.observe(f);
|
||||
const b = f.querySelector(".replay");
|
||||
if (b) b.onclick = () => replay(f);
|
||||
});
|
||||
root.querySelectorAll(".steps:not(.js)").forEach((s) => {
|
||||
s.classList.add("js");
|
||||
const ps = [...s.querySelectorAll(":scope>.step-panel")];
|
||||
let i = 0;
|
||||
const c = s.querySelector(".count"),
|
||||
p = s.querySelector(".prev"),
|
||||
n = s.querySelector(".next");
|
||||
const show = () => {
|
||||
ps.forEach((x, j) => x.classList.toggle("on", j === i));
|
||||
c.textContent = `${i + 1} / ${ps.length}`;
|
||||
p.disabled = i === 0;
|
||||
n.disabled = i === ps.length - 1;
|
||||
ps[i].querySelectorAll(".anim").forEach(replay);
|
||||
};
|
||||
p.onclick = () => {
|
||||
i--;
|
||||
show();
|
||||
};
|
||||
n.onclick = () => {
|
||||
i++;
|
||||
show();
|
||||
};
|
||||
show();
|
||||
});
|
||||
})(document)</script>
|
||||
</body></html>
|
||||
|
|
|
|||
BIN
packages/chord/docs/tutorial/screens/traces.png
Normal file
BIN
packages/chord/docs/tutorial/screens/traces.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
|
|
@ -1,17 +1,22 @@
|
|||
// The tutorial's app (`docs/tutorial/book.md`): a shared todo app built with the app kit, one worker per list. Each
|
||||
// numbered step is the tutorial's chapter of that number: the server and the owner's sign-in, a list on its worker,
|
||||
// routes, the page, the terminal, a theme plugin added while the app runs, a second user, languages, an edit applied in
|
||||
// place, and a list exported and imported. The lists plugin's own test is `plugins/lists/worker.test.ts`;
|
||||
// `screenshots.ts` shows the page in Chromium.
|
||||
// place, a list exported and imported, and a trace across the page, the server and a worker, read back from the app's
|
||||
// telemetry store. The lists plugin's own test is `plugins/lists/worker.test.ts`; `screenshots.ts` shows the page and
|
||||
// the trace viewer in Chromium; `trace.ts` reads a data directory's traces.
|
||||
// Run from packages/chord (with Node 26.5.1 or later the terminal client runs too; `--serve` keeps the app running):
|
||||
// node --conditions=source --experimental-strip-types test/examples/61-tutorial/main.ts
|
||||
import { execFile } from "node:child_process";
|
||||
import { cp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import type { StoredTrace } from "@earendil-works/pi-telemetry";
|
||||
import { renderTrace } from "@earendil-works/pi-telemetry/render";
|
||||
import { List } from "@todo/app/v1";
|
||||
import { WebSocket } from "ws";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { BACKGROUND_CONTEXT as context, withTelemetry } from "../../../src/context/index.ts";
|
||||
import {
|
||||
type ChordError,
|
||||
createHost,
|
||||
|
|
@ -24,6 +29,7 @@ import { type RunningTerminal, requireSandboxNode, startTerminal } from "../../.
|
|||
import { type Item, Todos } from "./plugins/lists/contract.ts";
|
||||
import { messages } from "./plugins/lists/messages.ts";
|
||||
import type { TodoPrincipal } from "./src/principals.ts";
|
||||
import { openTelemetry, type TodoTelemetry, telemetryFile } from "./src/telemetry.ts";
|
||||
import { startTodo, todoDirectories } from "./start.ts";
|
||||
|
||||
const { base, dataDir, root } = await todoDirectories();
|
||||
|
|
@ -31,7 +37,8 @@ const { base, dataDir, root } = await todoDirectories();
|
|||
// 1. Start the app: the kit makes the data directory, the server's store and host, HTTP and the owner link. Nobody is
|
||||
// signed in yet, so the page answers 401. The owner opens the link: its page posts the token from the URL's fragment
|
||||
// and gets a session cookie.
|
||||
const app = await startTodo(dataDir, root, context);
|
||||
const telemetry = await openTelemetry(telemetryFile(dataDir));
|
||||
const app = await startTodo(dataDir, root, telemetry, context);
|
||||
const link = new URL(app.ownerLink());
|
||||
const url = link.origin;
|
||||
console.log("1. owner link:", `http://127.0.0.1:<port>${link.pathname}#<token>`);
|
||||
|
|
@ -242,16 +249,52 @@ await app.importData(archive, List({ id: "groceries" }), context).catch((error:
|
|||
console.log(" importing over groceries:", error.message.replace(base, "…"));
|
||||
});
|
||||
|
||||
// 11. Telemetry. Every process of the app records to the store in the data directory (step 1 opened it for the server,
|
||||
// `worker.ts` opens it in each list's worker). A page stands in here as a client host of this process with a
|
||||
// recorder of its own on the same file, signed in with the owner's cookie of step 1. A click is one span of the
|
||||
// page; the call under it goes through the server's relay into the list's worker, and the carriers on the wire make
|
||||
// one trace of the three processes' spans. The store's `add` checks the text in a span of its own (`ctx.trace`).
|
||||
const pageTelemetry = await openTelemetry(telemetryFile(dataDir));
|
||||
const page = createHost<TodoPrincipal>({
|
||||
telemetry: pageTelemetry.store,
|
||||
onError: (error) => console.error("page:", error.kind),
|
||||
});
|
||||
await page.load([], context);
|
||||
const pageSocket = new WebSocket(`${url.replace(/^http/, "ws")}/chord`, { headers: { cookie } });
|
||||
page.connect(webSocketLink(pageSocket as unknown as WebSocketLike), { principal: { kind: "server" } }, context);
|
||||
const pageTodos = await page.get(Todos, { list: "groceries" }, context);
|
||||
const click = (text: string) =>
|
||||
pageTelemetry.store.startSpan({ name: "page.add" }, (span) => pageTodos.add(text, withTelemetry(span, context)));
|
||||
await click("honey");
|
||||
await click(" ").catch(() => "refused");
|
||||
pageTodos.release();
|
||||
await page.close(context);
|
||||
await pageTelemetry.close();
|
||||
// Each recorder writes what settled once a second: read until the worker's spans of both clicks are there.
|
||||
const [refusedTrace, honeyTrace] = await written(telemetry.store, "page.add", 2, "todos.validate");
|
||||
console.log("11. adding honey from the page:");
|
||||
printTelemetry(renderTrace(honeyTrace!.spans));
|
||||
// `trace.ts` reads the same file from a process of its own: the newest traces, then one by its id.
|
||||
const traceScript = fileURLToPath(new URL("./trace.ts", import.meta.url));
|
||||
const read = async (...args: string[]) =>
|
||||
(await promisify(execFile)(process.execPath, [...process.execArgv, traceScript, ...args])).stdout;
|
||||
console.log(" trace.ts <data directory> --name page.add:");
|
||||
printTelemetry(await read(dataDir, "--name", "page.add"));
|
||||
console.log(" trace.ts <data directory> <the refused one's id>:");
|
||||
printTelemetry(await read(dataDir, refusedTrace!.traceId));
|
||||
|
||||
if (process.argv.includes("--serve")) {
|
||||
console.log(`Open ${app.ownerLink()} (Ctrl-C to stop); terminal: 57-tui's terminal.ts with ${app.ownerTokenFile}`);
|
||||
console.log(`Traces: ${url}/traces, or trace.ts ${dataDir}`);
|
||||
} else {
|
||||
await terminal?.stop();
|
||||
groceries.release();
|
||||
adas.release();
|
||||
await ada.close(context);
|
||||
await app.close(context);
|
||||
await telemetry.close();
|
||||
await rm(base, { recursive: true, force: true });
|
||||
console.log("11. closed");
|
||||
console.log("12. closed");
|
||||
}
|
||||
|
||||
/** Todos as one line: `[x] milk (owner), [ ] eggs (owner)`. */
|
||||
|
|
@ -283,6 +326,33 @@ function screen(written: readonly string[]): string {
|
|||
.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
/** The newest `count` traces whose first span is `name`, once each has a span named `last` (the server's flushed). */
|
||||
async function written(
|
||||
store: TodoTelemetry["store"],
|
||||
name: string,
|
||||
count: number,
|
||||
last: string,
|
||||
): Promise<readonly StoredTrace[]> {
|
||||
for (;;) {
|
||||
await store.flush();
|
||||
const { traces } = await store.findTraces({ name, limit: count });
|
||||
const complete = traces.filter(({ spans }) => spans.some((span) => span.name === last));
|
||||
if (complete.length === count) return traces;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
/** Prints telemetry indented, as the same text at every run: span ids, lengths of time and byte counts masked. */
|
||||
function printTelemetry(text: string): void {
|
||||
const steady = text
|
||||
.replaceAll(/[0-9a-f-]{36}:\d+/g, "<id>")
|
||||
.replaceAll(/ \(\+?[\d.]+ms\)/g, "")
|
||||
.replaceAll(/duration=[\d.]+/g, "duration=<ms>")
|
||||
.replaceAll(/ [\d.]+ ms/g, " <ms>")
|
||||
.replaceAll(/\d+ bytes in \d+ messages/g, "<n> bytes in <n> messages");
|
||||
for (const line of steady.trim().split("\n")) console.log(` ${line}`);
|
||||
}
|
||||
|
||||
/** Whether this Node can run the terminal's sandbox. */
|
||||
function sandboxAvailable(): boolean {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// before the store goes live and again after every commit that writes the table. Each principal's `Todos` view adds
|
||||
// todos as that principal, and gives the owner `clear`, which leaves a notice: a message as data (`messages.message`),
|
||||
// never a sentence, so each viewer reads it in its own language. The store refuses a todo without text with Chord's
|
||||
// `invalid/args`.
|
||||
// `invalid/args`, in a span of its own (`ctx.trace`) that the app's traces show.
|
||||
|
||||
import {
|
||||
arr,
|
||||
|
|
@ -69,8 +69,11 @@ export default defineFacet((host) => {
|
|||
items: self.derive([newest], ([rows]) => [...rows].reverse()),
|
||||
notice: self.derive([notices], ([value]) => value.notice),
|
||||
add: async ({ text, by }, ctx) => {
|
||||
// A span of its own under the method's: a trace shows the check, and why it refused.
|
||||
await ctx.trace("todos.validate", { chars: text.length }, async () => {
|
||||
// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.
|
||||
if (text === "") throw invalid("args", "a todo needs text");
|
||||
});
|
||||
await self.tx(async (tx) => {
|
||||
// The number after the newest todo's: the store's transactions run one after another.
|
||||
const { items: last } = await tx.page(items, NEWEST, 1);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@todo/traces",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
// The trace viewer's server half: the owner's JSON routes over the app's telemetry store, which the page's viewer at
|
||||
// `/traces` reads (`src/web/traces.ts`). One object per server opens the store's file in the data directory (a native)
|
||||
// and contributes the routes while it is live; they read through the store's read interface only (`findTraces`,
|
||||
// `getTrace`) and never follow a link to another trace.
|
||||
|
||||
import { type Ctx, defineFacet, forbidden, local } from "@todo/app/v1";
|
||||
import { AppInfo, Http, openTelemetry, type StoredSpan, telemetryFile } from "@todo/app/v1/server";
|
||||
|
||||
/** The viewer's reader, once per server: empty params, so it lives as long as the server and may contribute. */
|
||||
const Traces = local("todo.traces", { version: 1, params: {}, members: {} });
|
||||
|
||||
/** How many traces the list shows, newest first. */
|
||||
const RECENT = 40;
|
||||
|
||||
export default defineFacet((host) => {
|
||||
host.serve(Traces, (_params, self) => {
|
||||
const info = self.get(AppInfo, {});
|
||||
// The server records to the same file with its own recorder; this one only reads.
|
||||
const telemetry = self.open(
|
||||
async () => openTelemetry(telemetryFile(info.dataDir.value)),
|
||||
(opened) => opened.close(),
|
||||
);
|
||||
self.contribute(Http, {
|
||||
routes: {
|
||||
"GET /api/traces": async (_request, ctx) => {
|
||||
ownerOnly(ctx);
|
||||
const { traces } = await telemetry.value.store.findTraces({ limit: RECENT });
|
||||
return { kind: "json", value: traces.map(({ traceId, spans }) => summaryOf(traceId, spans)) };
|
||||
},
|
||||
"GET /api/traces/:id": async (request, ctx) => {
|
||||
ownerOnly(ctx);
|
||||
const trace = await telemetry.value.store.getTrace(request.params.id!);
|
||||
if (trace === undefined) return { kind: "error", status: 404 };
|
||||
return { kind: "json", value: { id: trace.traceId, spans: rowsOf(trace.spans) } };
|
||||
},
|
||||
},
|
||||
});
|
||||
return {};
|
||||
});
|
||||
});
|
||||
|
||||
/** Refuses everyone but the owner: spans name who called what, and the route answers 403. */
|
||||
function ownerOnly(ctx: Ctx): void {
|
||||
if (ctx.principal?.kind !== "owner") throw forbidden("the traces are the owner's");
|
||||
}
|
||||
|
||||
/** The attributes of a trace's first span the list shows: an object's type and member, an HTTP route. */
|
||||
const DETAIL = ["type", "member", "route"];
|
||||
|
||||
/** A trace as the list shows it: its first span's name and details, its size, its processes, its length, its failure. */
|
||||
function summaryOf(id: string, spans: readonly StoredSpan[]) {
|
||||
const start = Math.min(...spans.map((span) => span.startedMs));
|
||||
// A stored trace has at least one span; its spans come in start order.
|
||||
const first = spans[0]!;
|
||||
return {
|
||||
id,
|
||||
name: first.name,
|
||||
detail: DETAIL.flatMap((key) => first.attributes[key] ?? []).join(" "),
|
||||
spans: spans.length,
|
||||
processes: new Set(spans.map(processOf)).size,
|
||||
durationMs: Math.max(...spans.map((span) => span.endedMs)) - start,
|
||||
error: spans.some((span) => span.status.status === "error"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The trace's spans as the tree shows them, parents before their children (in start order), each with its depth, its
|
||||
* process, its offset and length from the trace's start, and its attributes, events, links and error as text. A span
|
||||
* whose parent is not in the trace (another trace's, or one not stored) is a root.
|
||||
*/
|
||||
function rowsOf(spans: readonly StoredSpan[]) {
|
||||
const start = Math.min(...spans.map((span) => span.startedMs));
|
||||
const ids = new Set(spans.map((span) => span.id));
|
||||
// The store gives a trace's spans in start order, so each parent's children stay in it.
|
||||
const children = new Map<string | undefined, StoredSpan[]>();
|
||||
for (const span of spans) {
|
||||
const parent = span.parentId !== undefined && ids.has(span.parentId) ? span.parentId : undefined;
|
||||
const siblings = children.get(parent);
|
||||
if (siblings === undefined) children.set(parent, [span]);
|
||||
else siblings.push(span);
|
||||
}
|
||||
const rows: ReturnType<typeof rowOf>[] = [];
|
||||
const visit = (span: StoredSpan, depth: number): void => {
|
||||
rows.push(rowOf(span, depth, start));
|
||||
for (const child of children.get(span.id) ?? []) visit(child, depth + 1);
|
||||
};
|
||||
for (const root of children.get(undefined) ?? []) visit(root, 0);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** One span of the tree (see `rowsOf`). */
|
||||
function rowOf(span: StoredSpan, depth: number, start: number) {
|
||||
const { status } = span;
|
||||
return {
|
||||
name: span.name,
|
||||
depth,
|
||||
process: processOf(span),
|
||||
offsetMs: span.startedMs - start,
|
||||
durationMs: span.endedMs - span.startedMs,
|
||||
attributes: pairs(span.attributes),
|
||||
events: span.events.map((event) => [event.name, ...pairs(event.attributes)].join(" ")),
|
||||
// A reference to a span of another trace, shown and never followed.
|
||||
links: span.links.map((link) => `trace ${link.traceId} span ${link.spanId}`),
|
||||
error: status.status === "error" ? (status.error?.message ?? "failed") : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The recording process of `span`: the SQLite store's span ids are `<recorder>:<n>`, one recorder per process. */
|
||||
function processOf(span: StoredSpan): string {
|
||||
return span.id.slice(0, span.id.lastIndexOf(":"));
|
||||
}
|
||||
|
||||
/** Attributes as `key=value` texts, the values as JSON, fractions at two decimals (`duration=0.41`). */
|
||||
function pairs(attributes: StoredSpan["attributes"]): string[] {
|
||||
return Object.entries(attributes).map(([key, value]) => {
|
||||
const shown = typeof value === "number" && !Number.isInteger(value) ? value.toFixed(2) : JSON.stringify(value);
|
||||
return `${key}=${shown}`;
|
||||
});
|
||||
}
|
||||
|
|
@ -1,22 +1,27 @@
|
|||
// Screenshots of the tutorial's page (`docs/tutorial/screens/`), taken in headless Chromium: the page after the owner
|
||||
// signed in with the owner link, the page in the Harbor theme added while it runs, the page after an edit of the
|
||||
// lists plugin's web facet applied in place, and the page after the owner chose French, with the list's notice.
|
||||
// lists plugin's web facet applied in place, the page after the owner chose French, with the list's notice, and the
|
||||
// owner's trace viewer showing a todo added from a page.
|
||||
// Run from packages/chord, with Chromium (`CHROMIUM_BIN`, else `chromium-docker` on the `PATH`):
|
||||
// node --conditions=source --experimental-strip-types test/examples/61-tutorial/screenshots.ts docs/tutorial/screens
|
||||
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { LocaleChoice } from "../../../src/index.ts";
|
||||
import { WebSocket } from "ws";
|
||||
import { BACKGROUND_CONTEXT as context, withTelemetry } from "../../../src/context/index.ts";
|
||||
import { createHost, LocaleChoice, type WebSocketLike, webSocketLink } from "../../../src/index.ts";
|
||||
import { Browser, CHROMIUM } from "../../node/web/browser.ts";
|
||||
import { Todos } from "./plugins/lists/contract.ts";
|
||||
import type { TodoPrincipal } from "./src/principals.ts";
|
||||
import { openTelemetry, telemetryFile } from "./src/telemetry.ts";
|
||||
import { startTodo, todoDirectories } from "./start.ts";
|
||||
|
||||
const out = resolve(process.argv[2] ?? "screens");
|
||||
if (CHROMIUM === undefined) throw new Error("no Chromium: set CHROMIUM_BIN");
|
||||
await mkdir(out, { recursive: true });
|
||||
const { base, dataDir, root } = await todoDirectories();
|
||||
const app = await startTodo(dataDir, root, context);
|
||||
const telemetry = await openTelemetry(telemetryFile(dataDir));
|
||||
const app = await startTodo(dataDir, root, telemetry, context);
|
||||
|
||||
// The list as the tutorial leaves it after chapter 7: the owner's todos, one by the server, one by Ada.
|
||||
const as = async (principal: Parameters<typeof app.host.root>[2], texts: readonly string[]): Promise<void> => {
|
||||
|
|
@ -109,8 +114,53 @@ try {
|
|||
const lang = await browser.evaluate<string>("document.documentElement.lang");
|
||||
console.log(`after choosing French: lang="${lang}", ${JSON.stringify(french)} still typed`);
|
||||
console.log("the page did not reload:", await browser.evaluate<boolean>("window.marked === true"));
|
||||
|
||||
// 5. Honey added from a page that records, and a blank todo refused (main.ts's step 11: a client host with a recorder
|
||||
// of its own, here signed in with the owner token), then the owner opens the trace viewer on honey's trace.
|
||||
const pageTelemetry = await openTelemetry(telemetryFile(dataDir));
|
||||
const page = createHost<TodoPrincipal>({ telemetry: pageTelemetry.store, onError: () => {} });
|
||||
await page.load([], context);
|
||||
const token = (await readFile(app.ownerTokenFile, "utf8")).trim();
|
||||
const origin = new URL(app.ownerLink()).origin;
|
||||
const socket = new WebSocket(`${origin.replace(/^http/, "ws")}/chord`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
page.connect(webSocketLink(socket as unknown as WebSocketLike), { principal: { kind: "server" } }, context);
|
||||
const todos = await page.get(Todos, { list: "groceries" }, context);
|
||||
const click = (text: string) =>
|
||||
pageTelemetry.store.startSpan({ name: "page.add" }, (span) => todos.add(text, withTelemetry(span, context)));
|
||||
await click("honey");
|
||||
await click("").catch(() => "refused");
|
||||
todos.release();
|
||||
await page.close(context);
|
||||
await pageTelemetry.close();
|
||||
// Each recorder writes what settled once a second: read until the worker's span of honey's add is there.
|
||||
const honey = async () => {
|
||||
await telemetry.store.flush();
|
||||
const [, trace] = (await telemetry.store.findTraces({ name: "page.add", limit: 2 })).traces;
|
||||
return trace?.spans.some(({ name }) => name === "todos.validate") ? trace : undefined;
|
||||
};
|
||||
let trace = await honey();
|
||||
while (trace === undefined) {
|
||||
await new Promise((done) => setTimeout(done, 100));
|
||||
trace = await honey();
|
||||
}
|
||||
await browser.call("Emulation.setDeviceMetricsOverride", {
|
||||
width: 900,
|
||||
height: 640,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
});
|
||||
await browser.open(`${origin}/traces#${encodeURIComponent(trace.traceId)}`);
|
||||
await browser.wait("document.querySelectorAll('.traces-tree li').length === 6");
|
||||
await shoot("traces.png");
|
||||
const hosts = await browser.evaluate<number>(
|
||||
"new Set([...document.querySelectorAll('.traces-tree .traces-host')].map((tag) => tag.textContent)).size",
|
||||
);
|
||||
console.log(`the viewer shows page.add: 6 spans on ${hosts} hosts`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await app.close(context);
|
||||
await telemetry.close();
|
||||
await rm(base, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
// The todo app's API v1 for server facets (`@todo/app/v1/server`): the HTTP registry plugins add routes to, and the
|
||||
// kit's pick-a-name login as a facet a one-line plugin exports.
|
||||
// The todo app's API v1 for server facets (`@todo/app/v1/server`): the HTTP registry plugins add routes to, the kit's
|
||||
// pick-a-name login as a facet a one-line plugin exports, and the app's telemetry store (where it is, how a server
|
||||
// facet opens it to read) with the kit's `AppInfo`, which names the data directory.
|
||||
|
||||
import { ephemeralLoginFacet } from "../../../../../../src/node/app/index.ts";
|
||||
import type { TodoPrincipal } from "../../principals.ts";
|
||||
|
||||
export type { StoredSpan, StoredTrace } from "@earendil-works/pi-telemetry";
|
||||
export { AppInfo } from "../../../../../../src/node/app/index.ts";
|
||||
export { Http } from "../../../../../../src/node/http/index.ts";
|
||||
export { openTelemetry, type TodoTelemetry, telemetryFile } from "../../telemetry.ts";
|
||||
|
||||
/** The kit's ephemeral login: `POST /_chord/login { name }` signs in as `{ kind: "user", id, name }`. */
|
||||
export const login = ephemeralLoginFacet<TodoPrincipal>();
|
||||
|
|
|
|||
35
packages/chord/test/examples/61-tutorial/src/telemetry.ts
Normal file
35
packages/chord/test/examples/61-tutorial/src/telemetry.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// The todo app's telemetry store: one SQLite file in the data directory, `telemetry.sqlite`. The server and every
|
||||
// list's worker record to it, each process with a recorder of its own on the shared file; readers (the trace viewer,
|
||||
// `trace.ts`) open it the same way and read through the store's read interface.
|
||||
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { openNodeSqliteDatabase } from "../../../../src/sqlite/node.ts";
|
||||
import { createSqliteTelemetry, type SqliteTelemetry } from "../../../../src/telemetry-sqlite/index.ts";
|
||||
|
||||
/** The telemetry store as one process opened it: its recorder (an adapter and the reads) and its close. */
|
||||
export type TodoTelemetry = {
|
||||
readonly store: SqliteTelemetry;
|
||||
/** Flushes what the recorder still holds and closes the file. */
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
/** The telemetry store's file in the data directory `dataDir`. */
|
||||
export function telemetryFile(dataDir: string): string {
|
||||
return join(dataDir, "telemetry.sqlite");
|
||||
}
|
||||
|
||||
/** Opens the telemetry store at `file` for this process (its directory made, private, when missing). */
|
||||
export async function openTelemetry(file: string): Promise<TodoTelemetry> {
|
||||
// Spans carry who called what: the data directory is private before the store's first file appears in it.
|
||||
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
||||
const db = await openNodeSqliteDatabase(file);
|
||||
const store = await createSqliteTelemetry(db);
|
||||
return {
|
||||
store,
|
||||
close: async () => {
|
||||
await store.close();
|
||||
await db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
// The todo app's web entry, the base bundle the server builds at start: `startPage` with the app's web modules and
|
||||
// React, a core facet serving the `Panels` registry, and a page showing every panel the plugins contribute.
|
||||
// React, a core facet serving the `Panels` registry, and a page showing every panel the plugins contribute; at
|
||||
// `/traces`, the owner's trace viewer instead.
|
||||
|
||||
import * as v1 from "@todo/app/v1";
|
||||
import * as v1web from "@todo/app/v1/web";
|
||||
|
|
@ -11,12 +12,15 @@ import { defineFacet } from "../../../../../src/index.ts";
|
|||
import { HostProvider, useRegistry } from "../../../../../src/react/index.ts";
|
||||
import { startPage } from "../../../../../src/web/index.ts";
|
||||
import "./style.css";
|
||||
import { TraceViewer } from "./traces.ts";
|
||||
|
||||
/** The page's own facet: it serves `Panels`, composing the contributions in plugin-name order. */
|
||||
const core = defineFacet((host) => host.serve(v1web.Panels, { compose: (table) => table.map(({ value }) => value) }));
|
||||
|
||||
function Page() {
|
||||
const panels = useRegistry(v1web.Panels) ?? [];
|
||||
// The page's one other path (`start.ts` serves the page at `/` and `/traces`).
|
||||
if (location.pathname === "/traces") return h("main", { className: "wide" }, h("h1", null, "Todo"), h(TraceViewer));
|
||||
return h(
|
||||
"main",
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -3,3 +3,24 @@ body { margin: 0; font: 15px system-ui, sans-serif; background: var(--bg); color
|
|||
main { max-width: 26rem; margin: 1.5rem auto; padding: 0 1rem; }
|
||||
h1 { color: var(--accent); font-size: 1.4rem; margin: 0 0 1rem; }
|
||||
h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
|
||||
|
||||
/* The trace viewer (`/traces`): the list beside the chosen trace's tree, a colour per process. */
|
||||
main.wide { max-width: 60rem; }
|
||||
.traces { display: grid; grid-template-columns: 13rem 1fr; gap: 1rem; align-items: start; }
|
||||
.traces nav a { display: block; padding: 0.3rem 0.4rem; color: var(--fg); text-decoration: none; border-left: 2px solid transparent; }
|
||||
.traces nav a.traces-chosen { border-left-color: var(--accent); background: color-mix(in srgb, var(--fg) 8%, transparent); }
|
||||
.traces nav small { display: block; color: var(--muted); font-size: 0.75rem; }
|
||||
.traces-tree { list-style: none; margin: 0; padding: 0; font-size: 0.8rem; }
|
||||
.traces-tree li { padding: 0.25rem 0; border-bottom: 1px solid color-mix(in srgb, var(--muted) 30%, transparent); }
|
||||
.traces-span { display: flex; gap: 0.5rem; align-items: baseline; }
|
||||
.traces-ms { margin-left: auto; color: var(--muted); }
|
||||
.traces-host { font-size: 0.7rem; padding: 0 0.3rem; border-radius: 3px; color: var(--bg); }
|
||||
.traces-line { position: relative; height: 4px; margin: 0.2rem 0; }
|
||||
.traces-bar { position: absolute; top: 0; height: 4px; border-radius: 2px; }
|
||||
.traces-details { color: var(--muted); font: 0.72rem ui-monospace, monospace; overflow-wrap: anywhere; }
|
||||
.traces-error, .traces-failed b { color: var(--error); }
|
||||
.traces-link { font-style: italic; }
|
||||
.traces-host-0 { background: var(--accent); }
|
||||
.traces-host-1 { background: var(--success); }
|
||||
.traces-host-2 { background: var(--warning); }
|
||||
.traces-host-3 { background: var(--muted); }
|
||||
|
|
|
|||
147
packages/chord/test/examples/61-tutorial/src/web/traces.ts
Normal file
147
packages/chord/test/examples/61-tutorial/src/web/traces.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// The owner's trace viewer, the page at `/traces`: the newest traces of the app's telemetry store and the one chosen
|
||||
// (its id in the URL's fragment) as a tree: each span with its host, its length as a bar on the trace's time line, its
|
||||
// attributes and events, its error marked, and its links as references to other traces, never followed. It reads the
|
||||
// traces plugin's JSON routes (`plugins/traces/server.ts`), which answer the owner only.
|
||||
|
||||
import { createElement as h, useEffect, useState } from "react";
|
||||
|
||||
/** A trace in the list (`GET /api/traces`). */
|
||||
type Summary = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly detail: string;
|
||||
readonly spans: number;
|
||||
readonly processes: number;
|
||||
readonly durationMs: number;
|
||||
readonly error: boolean;
|
||||
};
|
||||
|
||||
/** A span of the tree (`GET /api/traces/:id`), parents before their children. */
|
||||
type Row = {
|
||||
readonly name: string;
|
||||
readonly depth: number;
|
||||
readonly process: string;
|
||||
readonly offsetMs: number;
|
||||
readonly durationMs: number;
|
||||
readonly attributes: readonly string[];
|
||||
readonly events: readonly string[];
|
||||
readonly links: readonly string[];
|
||||
readonly error: string | null;
|
||||
};
|
||||
|
||||
/** What a route answered: its JSON, or its status when it refused. */
|
||||
type Answer<T> = { readonly kind: "ok"; readonly value: T } | { readonly kind: "refused"; readonly status: number };
|
||||
|
||||
/** The viewer: the list, and the chosen trace's tree. */
|
||||
export function TraceViewer() {
|
||||
const [list, setList] = useState<Answer<readonly Summary[]>>();
|
||||
const [chosen, setChosen] = useState(fragment);
|
||||
const [trace, setTrace] = useState<Answer<{ readonly spans: readonly Row[] }>>();
|
||||
useEffect(() => {
|
||||
void read<readonly Summary[]>("/api/traces").then(setList);
|
||||
const follow = () => setChosen(fragment());
|
||||
addEventListener("hashchange", follow);
|
||||
return () => removeEventListener("hashchange", follow);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setTrace(undefined);
|
||||
if (chosen === "") return;
|
||||
void read<{ readonly spans: readonly Row[] }>(`/api/traces/${encodeURIComponent(chosen)}`).then(setTrace);
|
||||
}, [chosen]);
|
||||
if (list?.kind === "refused") return h("p", null, `The traces are the owner's (${list.status}).`);
|
||||
return h(
|
||||
"div",
|
||||
{ className: "traces" },
|
||||
h(
|
||||
"nav",
|
||||
null,
|
||||
h("h2", null, "Traces"),
|
||||
...(list?.value ?? []).map((summary) =>
|
||||
h(
|
||||
"a",
|
||||
{
|
||||
key: summary.id,
|
||||
href: `#${encodeURIComponent(summary.id)}`,
|
||||
className: summary.id === chosen ? "traces-chosen" : undefined,
|
||||
},
|
||||
h("span", { className: summary.error ? "traces-error" : undefined }, summary.name),
|
||||
h("small", null, summary.detail),
|
||||
h(
|
||||
"small",
|
||||
null,
|
||||
`${count(summary.spans, "span")} · ${count(summary.processes, "host")} · ${ms(summary.durationMs)}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
trace?.kind === "ok" ? h(Tree, { rows: trace.value.spans }) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/** One trace as a tree: a colour per host (the process that recorded the span), lengths on the trace's time line. */
|
||||
function Tree({ rows }: { readonly rows: readonly Row[] }) {
|
||||
const total = Math.max(...rows.map((row) => row.offsetMs + row.durationMs));
|
||||
const hosts = [...new Set(rows.map((row) => row.process))];
|
||||
return h(
|
||||
"ol",
|
||||
{ className: "traces-tree" },
|
||||
...rows.map((row, index) => h(Span, { key: index, row, host: hosts.indexOf(row.process), total })),
|
||||
);
|
||||
}
|
||||
|
||||
/** One span: its host, name and length, its bar, then its attributes, events, links and error. */
|
||||
function Span({ row, host, total }: { readonly row: Row; readonly host: number; readonly total: number }) {
|
||||
const indent = { paddingLeft: `${row.depth * 1.1}rem` };
|
||||
// Inline styles are set through the DOM by React, which the page's CSP allows (it refuses style attributes in HTML).
|
||||
const bar = {
|
||||
left: `${(row.offsetMs / total) * 100}%`,
|
||||
width: `${Math.max((row.durationMs / total) * 100, 0.5)}%`,
|
||||
};
|
||||
return h(
|
||||
"li",
|
||||
{ className: row.error === null ? undefined : "traces-failed" },
|
||||
h(
|
||||
"div",
|
||||
{ className: "traces-span", style: indent },
|
||||
h("span", { className: `traces-host traces-host-${host % 4}` }, `host ${host + 1}`),
|
||||
h("b", null, row.name),
|
||||
h("span", { className: "traces-ms" }, ms(row.durationMs)),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "traces-line" },
|
||||
h("span", { className: `traces-bar traces-host-${host % 4}`, style: bar }),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ className: "traces-details", style: indent },
|
||||
row.attributes.join(" "),
|
||||
...row.events.map((event) => h("div", { key: event }, `· ${event}`)),
|
||||
...row.links.map((link) => h("div", { key: link, className: "traces-link" }, `→ link to ${link}`)),
|
||||
row.error === null ? null : h("div", { className: "traces-error" }, `error: ${row.error}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads one of the viewer's routes. */
|
||||
async function read<T>(path: string): Promise<Answer<T>> {
|
||||
const response = await fetch(path, { credentials: "same-origin" });
|
||||
if (!response.ok) return { kind: "refused", status: response.status };
|
||||
// The traces plugin's JSON from the page's own server: the shape its routes write.
|
||||
return { kind: "ok", value: (await response.json()) as T };
|
||||
}
|
||||
|
||||
/** The chosen trace's id: the URL's fragment. */
|
||||
function fragment(): string {
|
||||
return decodeURIComponent(location.hash.slice(1));
|
||||
}
|
||||
|
||||
/** `n` things: `1 span`, `6 spans`. */
|
||||
function count(n: number, thing: string): string {
|
||||
return `${n} ${thing}${n === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/** A length in milliseconds, as the viewer shows it. */
|
||||
function ms(value: number): string {
|
||||
return value < 10 ? `${value.toFixed(2)} ms` : `${Math.round(value)} ms`;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// The todo app's start: what the app decides itself, given to the kit's `startServer`: its name and data directory,
|
||||
// its API, its facet kinds, its principals, its one worker kind (a list), its page and its terminal client. The kit
|
||||
// does the rest. The examples call `startTodo` with a copy of `plugins/` they may edit; a real app's start script calls
|
||||
// its API, its facet kinds, its principals, its one worker kind (a list), its page and its terminal client, and its
|
||||
// telemetry store (one SQLite file in the data directory, shared by the server and the workers). The kit does the rest. The examples call `startTodo` with a copy of `plugins/` they may edit; a real app's start script calls
|
||||
// it with the data directory from its command line and prints the owner link.
|
||||
|
||||
import { cp, mkdtemp } from "node:fs/promises";
|
||||
|
|
@ -13,6 +13,7 @@ import type { Context } from "../../../src/context/index.ts";
|
|||
import { type App, startServer } from "../../../src/node/app/index.ts";
|
||||
import { type PageAssets, pageScripts } from "../../../src/node/surfaces/index.ts";
|
||||
import type { TodoPrincipal } from "./src/principals.ts";
|
||||
import { type TodoTelemetry, telemetryFile } from "./src/telemetry.ts";
|
||||
|
||||
/** The app's API: its name, its versions (newest first), and this process's instances of its modules. */
|
||||
export const API = {
|
||||
|
|
@ -34,8 +35,16 @@ function page(request: { readonly nonce: string }, assets: PageAssets): string {
|
|||
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Todo</title>${pageScripts(assets, request.nonce)}</head><body><div id="root"></div></body></html>`;
|
||||
}
|
||||
|
||||
/** Starts the todo app on a free loopback port, with its data in `dataDir` and its built-in plugins in `root`. */
|
||||
export function startTodo(dataDir: string, root: string, context: Context): Promise<App<TodoPrincipal>> {
|
||||
/**
|
||||
* Starts the todo app on a free loopback port, with its data in `dataDir`, its built-in plugins in `root`, recording to
|
||||
* `telemetry` (the store in `dataDir`).
|
||||
*/
|
||||
export function startTodo(
|
||||
dataDir: string,
|
||||
root: string,
|
||||
telemetry: TodoTelemetry,
|
||||
context: Context,
|
||||
): Promise<App<TodoPrincipal>> {
|
||||
return startServer<TodoPrincipal>(
|
||||
{
|
||||
name: "todo",
|
||||
|
|
@ -45,17 +54,21 @@ export function startTodo(dataDir: string, root: string, context: Context): Prom
|
|||
api: API,
|
||||
kinds: KINDS,
|
||||
principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", list: address.key }) },
|
||||
// The server's spans and metrics; each list's worker opens the same file (`worker.ts`).
|
||||
telemetry: telemetry.store,
|
||||
workers: [
|
||||
{
|
||||
kind: v1.List,
|
||||
entry: fileURLToPath(new URL("./worker.ts", import.meta.url)),
|
||||
execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"],
|
||||
env: { TODO_TELEMETRY: telemetryFile(dataDir) },
|
||||
start: () => ({}),
|
||||
// Who may reach a list's worker: everyone signed in, and the server itself.
|
||||
authorize: (principal) => principal.kind !== "worker",
|
||||
},
|
||||
],
|
||||
web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page },
|
||||
// The page, and the owner's trace viewer: the same page, which shows the viewer at `/traces`.
|
||||
web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page, paths: ["/", "/traces"] },
|
||||
tui: { entry: fileURLToPath(new URL("./src/tui/main.ts", import.meta.url)) },
|
||||
onError: (error) => console.error("todo:", error.kind, error.error),
|
||||
},
|
||||
|
|
|
|||
39
packages/chord/test/examples/61-tutorial/trace.ts
Normal file
39
packages/chord/test/examples/61-tutorial/trace.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// The todo app's trace reader: a script reading the telemetry store of a data directory (`telemetry.sqlite`) through
|
||||
// the store's read interface, while the app runs or after. Without a trace id it prints the newest traces (those whose
|
||||
// first span is `--name`, when given) and the bytes on the links; with one, that trace as the renderer prints it.
|
||||
// Run from packages/chord:
|
||||
// node --conditions=source --experimental-strip-types test/examples/61-tutorial/trace.ts <data directory> [--name <span>]
|
||||
// node --conditions=source --experimental-strip-types test/examples/61-tutorial/trace.ts <data directory> <trace id>
|
||||
import { parseArgs } from "node:util";
|
||||
import { renderTrace } from "@earendil-works/pi-telemetry/render";
|
||||
import { openTelemetry, telemetryFile } from "./src/telemetry.ts";
|
||||
|
||||
const { values, positionals } = parseArgs({ allowPositionals: true, options: { name: { type: "string" } } });
|
||||
const [dataDir, id] = positionals;
|
||||
if (dataDir === undefined) throw new Error("usage: trace.ts <data directory> [--name <span>] [<trace id>]");
|
||||
// A recorder of this process on the shared file, which records nothing: the reads are what the script uses.
|
||||
const telemetry = await openTelemetry(telemetryFile(dataDir));
|
||||
try {
|
||||
if (id !== undefined) {
|
||||
const trace = await telemetry.store.getTrace(id);
|
||||
console.log(trace === undefined ? `no trace ${id}` : renderTrace(trace.spans));
|
||||
} else {
|
||||
// Complete traces, newest first; a trace's first span (its root, by start time) names it.
|
||||
const { traces } = await telemetry.store.findTraces({ name: values.name, limit: 10 });
|
||||
for (const { traceId, spans } of traces) {
|
||||
const start = Math.min(...spans.map((span) => span.startedMs));
|
||||
const length = Math.max(...spans.map((span) => span.endedMs)) - start;
|
||||
const failed = spans.some((span) => span.status.status === "error") ? " error" : "";
|
||||
console.log(`${traceId} ${spans[0]!.name} ${spans.length} spans ${length.toFixed(2)} ms${failed}`);
|
||||
}
|
||||
// Metrics are stored per series and 10 s: a series is one set of attributes, here the direction.
|
||||
for (const direction of ["sent", "received"]) {
|
||||
const rows = await telemetry.store.findMetrics({ name: "chord.link.bytes", attributes: { direction } });
|
||||
const bytes = rows.reduce((sum, row) => sum + row.sum, 0);
|
||||
const messages = rows.reduce((sum, row) => sum + row.count, 0);
|
||||
console.log(`chord.link.bytes ${direction}: ${bytes} bytes in ${messages} messages`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await telemetry.close();
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
// The tutorial's checks (`docs/tutorial/book.md`): every TypeScript, JSON and CSS block of the book is quoted from this
|
||||
// example's files (`<!-- quote: path -->` above it) and fails here when the file no longer has those lines; the
|
||||
// example's steps run (`main.ts`, and `screenshots.ts` with Chromium), and every console output the book shows
|
||||
// (`<!-- output: main -->`, `<!-- output: screenshots -->`) is among the lines they print, in order. The terminal's
|
||||
// example's steps run (`main.ts`, which runs `trace.ts` on its data directory, and `screenshots.ts` with Chromium), and
|
||||
// every console output the book shows (`<!-- output: main -->`, `<!-- output: screenshots -->`) is among the lines they
|
||||
// print, in order: the telemetry chapter's traces, `trace.ts`'s reads and the viewer's screenshot too. The terminal's
|
||||
// output (`<!-- output: tui -->`) is checked when a Node for its sandbox is there (`CHORD_TUI_NODE`, else `node26`).
|
||||
// The p2 chapter's blocks (`<!-- p2: path -->`) quote p2's sources, which live outside this repository.
|
||||
// Run from packages/chord:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
// The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects
|
||||
// to the server and loads the plugins' worker facets.
|
||||
// to the server and loads the plugins' worker facets. The worker records to the app's telemetry store, the file its
|
||||
// server named in the environment: its methods called through the server continue the caller's trace.
|
||||
|
||||
import { startWorker } from "../../../src/node/app/index.ts";
|
||||
import { openTelemetry } from "./src/telemetry.ts";
|
||||
import { API, KINDS } from "./start.ts";
|
||||
|
||||
startWorker({ api: API, kinds: KINDS, onError: (error) => console.error("list:", error) });
|
||||
const telemetry = await openTelemetry(process.env.TODO_TELEMETRY!);
|
||||
startWorker({ api: API, kinds: KINDS, telemetry: telemetry.store, onError: (error) => console.error("list:", error) });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue