Live tail
Stream records as they arrive over Server-Sent Events, filtered by the server, using a single-use ticket rather than a credential in the URL.
The live tail streams records to you as they are ingested. It is two calls: you exchange your credential for a ticket, then open the stream with it.
The SDK does both, and handles the reconnection trap described below:
import { openLiveTail } from "@xeonr/forest/live";
const tail = openLiveTail({
url: "https://forest.prod.wtf",
token, // a token carrying a read grant
project: "01j9x…",
predicates: [{ field: "level", op: "in", values: ["error", "fatal"] }],
onRecord: (r) => console.log(r.message),
onRefused: (why) => console.error("the tail will not start:", why),
});
// later
tail.close();POST /live/ticket Authorization: Bearer <token>
{ "project": "01j9x…", "predicates": [ … ] }
-> { "ticket": "lt_…", "expiresIn": 30 }
GET /live/stream?ticket=lt_…Why a ticket
A stream cannot carry an Authorization header — EventSource does not send
one — and a token in a query string ends up in access logs, proxy traces and
anything else that records a URL. A ticket is opaque, single-use and dead in 30
seconds, so a copy of one is worth nothing to whoever finds it.
It also means the stream is fully resolved before it opens: a filter the stream
cannot evaluate fails at POST /live/ticket, where you can read the error,
rather than becoming a stream that silently shows you nothing.
Filtering
Predicates are applied by the server, so a narrow view costs you nothing on the wire:
{
"project": "01j9x…",
"primitives": ["logs"],
"predicates": [
{ "field": "level", "op": "in", "values": ["error", "fatal"] },
{ "field": "attr:session_id", "op": "eq", "values": ["s-9"] }
]
}op is one of eq, in, neq, contains, prefix, gt, gte, lt,
lte, defaulting to eq. Values within a predicate are OR; predicates AND
together. This is the same vocabulary the query API uses, and the same one a
token's read grant is written in — your filter is applied on top of whatever
your token already constrains, and can only narrow further.
primitives limits the stream to some of logs, metrics and events. Omit
it for all three.
A field must be filterable on every primitive the stream will carry. Filtering
on level — which only logs have — while streaming metrics is an error, fixed
by naming "primitives": ["logs"]. Refusing is deliberate: the alternative is a
stream that quietly drops half of what you asked for.
Records
Each SSE message carries one record:
{
"kind": "log",
"time": "2026-08-30T12:00:00Z",
"clientId": "device-4711",
"level": "error",
"message": "upload failed",
"logger": "sync",
"attrs": { "session_id": "s-9" }
}kind is log, metric or event. Metrics carry name, value, unit and
metricKind; events carry name and an optional value. A comment frame
arrives every 20 seconds to keep intermediaries from reaping an idle stream.
Reconnecting
A ticket is spent when the stream opens, so a client that reconnects must
request a new one. EventSource's built-in reconnection is actively wrong
here: its retry replays the same URL, arrives with a spent ticket, and fails
forever behind a silent error event.
Separate the two failures, too. A ticket refused with 400 or 403 — a filter the stream cannot evaluate, a grant that does not cover the project — is a verdict on the request, and asking again cannot change the answer; retrying it is an invisible infinite loop. Anything else is transient and worth a backoff.
openLiveTail above does all of this. Rolling your own:
async function connect() {
const res = await fetch("/live/ticket", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ project, predicates }),
});
if (res.status === 400 || res.status === 403) {
// Permanent. Report it; do not retry.
throw new Error(await res.text());
}
const { ticket } = await res.json();
const es = new EventSource(`/live/stream?ticket=${encodeURIComponent(ticket)}`);
es.onmessage = (e) => render(JSON.parse(e.data));
es.onerror = () => {
// Close it: leaving it open means EventSource retrying a spent ticket.
es.close();
setTimeout(connect, backoff());
};
}The stream is at /live/stream, not /live — the console is served from the
same origin and /live is its own page.
From another origin
The ticket exchange, the stream and the query API all send
Access-Control-Allow-Origin: *, so a page you host can use a read token
directly. They never allow credentials, which is what makes that safe — a
browser will not attach our session cookie to your requests, so your page acts
with the token you gave it and nothing else. Console administration is not
reachable cross-origin at all.
Errors
| Status | Meaning |
|---|---|
401 a stream ticket is required | No ticket parameter |
401 unknown or expired ticket | Spent, expired, or never existed — the three are deliberately indistinguishable |
403 not authorized | Your token's grant does not cover this project or primitive |
400 | The filter could not be evaluated; the message names the field or operator |