Xeonr Developer Docs

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

StatusMeaning
401 a stream ticket is requiredNo ticket parameter
401 unknown or expired ticketSpent, expired, or never existed — the three are deliberately indistinguishable
403 not authorizedYour token's grant does not cover this project or primitive
400The filter could not be evaluated; the message names the field or operator

On this page