Keep a Log of What Happens and Query It
The shop wants to know what sells, what customers search for and where they leave the storefront, and wants to ask in words rather than build a dashboard. Chronicles keep a log of the application in a store the Context names: the changes of the components that name them, facts an operation sends, and what the storefront sends. An agent reads it over the gateway’s MCP. The store Toa provides is ClickHouse.
§Turn it on
Chronicles do two things: the service chronicles publishes every row of the log to a broker,
and reads them back from a store. What takes the rows from the one into the other is the
store’s setup, not the application’s: Toa ships it as reference SQL for ClickHouse, and a platform
that runs many applications runs one broker and one ClickHouse for all of them.
Locally, the store is the ClickHouse beside the broker the application already has:
# docker-compose.yaml
services:
clickhouse:
image: clickhouse/clickhouse-server:26.8.34.13
ports:
- "8123:8123"
environment:
CLICKHOUSE_USER: developer
CLICKHOUSE_PASSWORD: secret
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 Then set it up as the reference says, once — the shared tables, with the engine that takes the rows off the broker, and the application’s user, named after the Context, which reads its rows and nothing else:
$ sed -e 's/{broker}/rabbitmq:5672/g' -e 's/{broker_username}/developer/g' -e 's/{broker_password}/secret/g'
node_modules/@toa.io/extensions.chronicles/store/clickhouse.sql
| docker compose exec -T clickhouse clickhouse-client -u developer --password secret --multiquery
$ sed -e 's/{scope}/shop/g' -e 's/{password}/secret/g'
node_modules/@toa.io/extensions.chronicles/store/clickhouse.scope.sql
| docker compose exec -T clickhouse clickhouse-client -u developer --password secret --multiquery {broker} is the broker as ClickHouse reaches it — the compose service’s name and port — with
its user and password; {scope} is the Context’s name, and {password} its user’s, secret as npm run env writes it.
Then the Context names both:
# context.toa.yaml
chronicles:
broker: amqp://localhost:5672
store:
provider: clickhouse
url: http://localhost:8123
chronicles@production:
broker: amqp://analytics.internal
store:
provider: clickhouse
url: http://analytics.internal:8123
retention: 730 # days a row of events and of encounters is kept; absent, kept The log is kept by a service of its own, chronicles, which takes the events the components
declare off the broker they are published to, as any receiver does, and publishes them. Add it to
what sys serves:
// ecosystem.config.js
{ ...common, name: 'sys', args: 'serve configuration exposition introspection chronicles' }, $ npm run env
$ docker compose up -d
$ npm run restart npm run env writes the credentials into .env: the broker’s user, and the store’s user named
after the Context. In a deployment they are a secret, which only the
service is given:
$ npx toa conceal chronicles broker_username=... broker_password=... store_username=... store_password=... While the broker of the log is away, nothing waits for it: what is to be published waits in the application’s own queues, and is published once it is back.
§Write without reading
Where the log is read elsewhere — by a platform that runs the application and reads the logs of
all it runs — the Context names no store. The application then writes the log and does not read
it: it has none of the routes and tools of Read the log, and its secret holds
the broker’s user alone.
# context.toa.yaml
chronicles@production:
broker: amqp://analytics.internal
encounters: true $ npx toa conceal chronicles broker_username=... broker_password=... §Log the changes of a component
A component declares the events that are what happened to it, and the one that is the order as it stands — and of each, what the log keeps, with what every field means:
# components/store.orders/manifest.toa.yaml
chronicles:
identity: customer
events:
created:
description: An order was placed
properties:
total:
type: integer
description: The amount of the order, in cents
items:
type: array
description: What was ordered
items:
type: object
properties:
sku:
type: string
description: The code of the product
quantity:
type: integer
description: Units of the product
state:
event: snapshot
description: An order as it stands
properties:
status:
type: string
enum: [new, paid, shipped]
description: Where the order is in its life
total:
type: integer
description: The amount of the order, in cents created is an event every component has, and a row of the log every time an order is placed. snapshot is an event of the order’s own, which nothing else has to consume, and the log keeps
the latest of it for every order:
// components/store.orders/events/snapshot.ts
import type { Event } from '../types/index.d.ts'
export function payload (event: Event) {
return event.state
} What is written is what the declaration names, at every depth, and nothing else: the customer’s
email and address, which the order has, never reach the store, and neither does a field the order
gains later until it is declared. So the declaration is where personal data is kept out. A payload
whose declared field is of another type — a total that is a string — is not logged, and waits in
the parking queue of the service.
Every field has a type and a description, at every depth, or the component is refused when it
is read. A field is a string, an integer, a number, a boolean, an object of fields or an array of any of these; a string may name an enum or a format — date-time, date, uuid, ipv4 — which is the type it is read as. The descriptions are what an agent reads to know that total is in cents.
Which order changed and its version are read from the payload’s id and VERSION, before the
declaration: a state must carry both, or it is refused, and neither is declared. When it changed
is the payload’s UPDATED, which the order as it stands carries, or else the moment the
component made the change; timestamp: placed names another property of the payloads that says
when, and timestamp: ~ none. A change the service takes late is still logged at its own time. identity names the order’s property that says whose it is, which is
the identity column of its rows; absent, it is identity, and identity: ~ is none. A change is
logged however long the service is away, and once however often it is delivered; one committed
before the service first ran is not.
§Send a fact
A search changes nothing, and is still worth knowing:
// components/store.search/operations/find.ts
import type { Context, FindInput } from '../types/index.d.ts'
export async function computation (input: FindInput, context: Context) {
const results = await query(input.text, context)
await context.chronicles.send('searched', { text: input.text, results: results.length })
return results
} # components/store.search/manifest.toa.yaml
chronicles:
facts:
searched:
description: A customer searched the catalogue
properties:
text:
type: string
description: What was typed
results:
type: integer
description: How many products were found A fact is declared as an event is, and send throws for a name the component does not declare or
a field of another type. It resolves once the broker has the fact, which then waits for the
service as an event does. An operation that knows whose the search is passes it as a third
argument, { identity: input.customer }: the service is not told who called the operation.
§Take encounters from the storefront
What the storefront tells of its visitors — a page viewed, a button pressed — is an encounter:
# context.toa.yaml
chronicles:
encounters: true The storefront keeps an id of the visitor, in localStorage, and one of the session, and posts
batches of what happened, at most 100 at a time. A signed-in customer’s go to the route of their
identity, with their token, and are that customer’s rows:
await fetch(`https://api.store.example/chronicles/encounters/${customer.id}/`, {
method: 'POST',
keepalive: true,
headers: { authorization: `Token ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({
sent: Date.now(), visitor, session,
events: [{ id: crypto.randomUUID(), name: 'page_viewed', time: Date.now(), payload: { path: location.pathname } }]
})
}) A visitor’s go to the route of no one, which takes no credentials, so navigator.sendBeacon can
send them as the page closes, as a form whose events is their JSON:
navigator.sendBeacon('https://api.store.example/chronicles/encounters/', new URLSearchParams({
sent: Date.now(), visitor, session,
events: JSON.stringify([{ id: crypto.randomUUID(), name: 'page_left', time: Date.now(), payload: { path: location.pathname } }])
})) 202 Accepted
accepted: 1 visitor and session are the storefront’s own, at most 64 characters each, and either may be
left out. The same visitor before signing in and after is what tells which visits became which
customer: the rows of the route of an identity have both. A visitor is not a person — a customer
may have many, and a browser that clears its storage is a new one.
A batch says when it is sent, by the browser’s clock. An event is { id, name, time, payload }:
its own id, which a retry sends again; lowercase letters, digits and underscores; when it
happened, by the same clock; an object of at most 16 KiB.
Who may send to which route is in Who may do what.
A payload is logged as it was sent, whatever its shape: a storefront that changes what an event
carries still has old pages open for months, so a shape that changes is a new name, checkout_started_v2, rather than a new meaning of the old one.
A browser’s clock may be off by anything; what it measures between two of its moments is not. So
an encounter’s time is when the gateway received the batch, received, less how long before sent the event happened: the gateway’s clock, at the distance the browser measured. A batch a
page kept while offline is logged at when its events happened.
Every encounter also has the ip and the country of its request, as the gateway reads them from
the headers exposition.ip and exposition.country name. An
address is personal data, and the log erases nothing, so it keeps the network the address is in —
a /24 of IPv4, a /48 of IPv6 — unless the Context says chronicles.ip: full.
Every encounter also has the origin of the page that sent it and the browser’s agent, from the
request’s Origin and User-Agent headers, and what the agent says the client is, by name and
without versions: browser — Chrome, Safari, Edge, Firefox, Opera, Samsung Internet; os — Windows, macOS, iOS, Android, ChromeOS, Linux; and device — desktop, mobile, tablet, or bot for a crawler or a program, which a question of who visits leaves out.
A body cannot say any of them for the request. Safari on an iPad says it is a Mac, and is counted
as one.
§Document what it sends
What each encounter is, and what its fields mean, is the configuration of chronicles.encounters:
# context.toa.yaml
configuration:
chronicles.encounters:
page_viewed:
description: A page was shown
fields:
path: The URL path, without the query
page_left:
description: The visitor closed the page or went elsewhere
fields:
path: The URL path of the page left It is what an agent reading the log is told of each encounter, and what whoever writes the
storefront reads, at GET /chronicles/encounters/.
It documents, and checks nothing: an encounter it does not name is logged as any other.
§Read the log
The log is three tables. Every row has scope, the application it is of, and an application
reads its own.
events holds a row for everything a component did. The order placed and the search made above
are two rows of it:
label | component | entity | version | payload |
|---|---|---|---|---|
store.orders.created | store.orders | 9c1f… | 0 | { total: 12000, items: [...] } |
store.search.searched | store.search | NULL | NULL | { text: "teapot", results: 3 } |
A change is a row under <component>.<event> for every event it renders, and names the order
that changed and its version. A fact is a row under <component>.<name>, and names none. Every row
also has time, when it happened — never when it was written — and id, the same however often
it is delivered.
states holds every order as it stands: the snapshot of its latest version, by component and entity. It is the order now and nothing before it: what an order was yesterday is read from its
events, so an event that a question of history depends on has to carry what the question asks.
encounters holds what the storefront sent: name, time, received, visitor, session, identity, ip, country, origin, agent, browser, os, device and payload.
Of every declared field, the answer to GET /chronicles/queries/ says its type, what it means,
and how to read it in that type:
SELECT toDate(time) AS day, count() AS orders,
sum(JSONExtract(ifNull(toJSONString(payload.total), 'null'), 'Nullable(Int64)')) / 100 AS revenue
FROM events FINAL WHERE label = 'store.orders.created' GROUP BY day ORDER BY day A value that is not of its type reads as NULL. A query is written in the store’s dialect. In
ClickHouse, the tables are read with FINAL, which collapses what was delivered twice:
SELECT name, count() FROM encounters FINAL WHERE device != 'bot' GROUP BY name
SELECT visitor, groupUniqArray(identity) FROM encounters FINAL
WHERE identity IS NOT NULL GROUP BY visitor Nothing arrives in order: order by time, or by version within one order.
§Read it over HTTP
The log is read through these routes of the gateway:
| Route | Does |
|---|---|
GET /chronicles/queries/ | a guide to reading it; the labels, states and encounters with their fields; the views |
POST /chronicles/queries/ | one statement, { sql }, answered { columns, rows, truncated } |
PUT /chronicles/queries/views/:name/ | defines a view, { sql, title?, description?, refresh? } |
DELETE /chronicles/queries/views/:name/ | drops a view |
GET /chronicles/encounters/ | what the encounters are, as the configuration documents them |
Who may call each is in Who may do what.
GET /chronicles/queries/ tells of every declared label and state what it means, its view, how
many rows it has, the first and the last time of them — which tells whether rows still arrive —
and its fields, with their types and descriptions. Of every
encounter it tells the fields the latest payloads had, with the types they were sent as — two
types of one field are two versions of the storefront — and what the configuration says of them.
A query reads the log and nothing else, and cannot write: for at most 30 seconds, 10,000 rows and
64 MiB, with at most 4 queries at once. A view is a statement a query could run; give it a title and a description, which tell an agent that finds it what it counts. With refresh, in seconds
and at least an hour, it is computed on that cycle, read as a table, and told with when it was last refreshed. A store has at most 50 views. A refusal is a 422 with its code: REFUSED, with what
the store said in cause.message, for a statement; RESERVED, for the name of a table or of a
declared view, EXHAUSTED and NOT_FOUND for a view.
§Try it
POST /chronicles/queries/ HTTP/1.1
host: api.store.example
authorization: Basic cm9vdDpzZWNyZXQjMTIzNA==
accept: application/yaml
content-type: application/yaml
sql: SELECT status, count() AS n FROM store_orders GROUP BY status 201 Created
columns:
- name: status
type: LowCardinality(Nullable(String))
- name: n
type: UInt64
rows:
- [new, 3]
- [paid, 9]
truncated: false §Let an agent ask
Publish the gateway’s tools, and each of these routes is a tool an agent
holding its role is offered — chronicles.queries.GET, chronicles.queries.POST, chronicles.queries.views._name.PUT, chronicles.queries.views._name.DELETE and chronicles.encounters.GET:
# context.toa.yaml
exposition:
mcp:
name: Store An agent asks chronicles.queries.GET what the log holds and how to read it — a guide Toa writes,
for the log and for its store — then writes its queries and the views it keeps. What is particular
to the application is in what it declares and documents, and in exposition.mcp.instructions.
§Who may do what
| Route | Who may call | Limit |
|---|---|---|
POST /chronicles/encounters/ | anyone, with no credentials | 60 a minute per address |
POST /chronicles/encounters/:identity/ | the identity, by its own credentials | 60 a minute per identity |
GET /chronicles/encounters/ | system:chronicles:encounters | |
GET /chronicles/queries/ | system:chronicles:query | |
POST /chronicles/queries/ | system:chronicles:query | 30 s, 10,000 rows, 64 MiB, 4 at once |
PUT /chronicles/queries/views/:name/ | system:chronicles:views | 50 views |
DELETE /chronicles/queries/views/:name/ | system:chronicles:views |
A caller without what a route takes is answered 403, and past its limit 429; the limit per
address takes exposition.ip to name the address. An agent
is offered the tool of a route only where it holds the route’s role.
The roles are what to grant whom — see Give Staff Wider Access:
system:chronicles:queryreads every row of the log, with the customers’ identities, networks, origins and browsers in it. It is access to personal data, and is granted as that.system:chronicles:viewsdefines and drops the views every reader reads. A view reads only what a query could, and changes nothing of the log, but it changes what other readers are told a view counts.system:chronicles:encountersreads what the encounters are, and no row: it is for whoever writes the storefront.system:chroniclesis all three.
The storefront sends events and nothing that says who or where the visitor is. identity is the
route’s, whose credentials have to be the identity’s; ip, country, origin and agent are
what the gateway read off the request, and a body cannot say any of them. Anything else in a
payload is what the browser said — a user there is not a user — so a question of who sent an
encounter asks identity.
A query runs as the application’s user of the store, which the store’s setup lets read the rows of its own scope, keep views in a database named after it, and lift none of its limits; Toa asks a query to be read-only besides. A user the setup gives no row policy reads every scope — the platform’s, which reads the applications it runs.