@xeonr/trail
v0.1.1
Published
Client SDK for trail, the Xeonr platform audit log: typed Connect clients, a filter builder, token providers, a server-side read-token minter and a batching event writer.
Readme
@xeonr/trail
Client SDK for trail, the Xeonr platform audit log. Typed Connect clients for every service, a filter builder, token providers, and a server-only entry that mints read tokens and batches writes.
pnpm add @xeonr/trail @bufbuild/protobuf @connectrpc/connectClients
import { createTrailClient, bearer, cookie, filters, SortOrder } from "@xeonr/trail";
// A service calling with a service-account token:
const trail = createTrailClient({ baseUrl: "https://trail.xeonr.io", token: bearer(() => tokenSource.get()) });
// A console on the same origin as trail's BFF (httpOnly cookie, no header):
const trail = createTrailClient({ baseUrl: "/", token: cookie() });
const res = await trail.events.searchEvents({
project: "dbaas",
q: "prod",
filters: filters().eq("sub_type_key", "database.created").in("properties.namespace", ["ns-1"]).build(),
facetBy: ["sub_type_key", "outcome"],
order: SortOrder.DESC,
limit: 50,
});createTrailClient returns { projects, catalog, ingest, events, me, sinks, transport }.
Options: baseUrl, token?, credentials?, fetch?, interceptors?, useBinaryFormat?.
Every generated message, enum and service is re-exported from the root
(AuditEvent, Filter, FilterOp, Severity, EventsService, …).
Filters
filters() builds the one filter vocabulary trail uses everywhere — queries,
saved views, reader grants, minted tokens, sinks. Filters AND together; the
values of one in OR together. Fields are core fields (sub_type_key,
actor.urn, scope.team_urn, subjects, …) or properties.<declared name>.
| method | op |
|---|---|
| eq(field, v) / in(field, [v…]) / notIn(field, [v…]) | EQ / IN / NOT_IN |
| prefix(field, s) / gte(field, v) / lte(field, v) / exists(field) | PREFIX / GTE / LTE / EXISTS |
filtersToJSON / filtersFromJSON round-trip a list through plain JSON (what
a saved view persists); filtersFromRecord({ "properties.namespace": ["ns-1"] })
is the shorthand.
Token providers
bearer(token | () => token)— a bearer, fetched per request.cookie()— no header,credentials: "include": the same-origin BFF case.minted(getToken)— a read token your backend mints; cached until near itsexp, one in-flight fetch shared.
authInterceptor(provider) is exported for a transport you build yourself.
@xeonr/trail/server — Node only
Takes your project key secret. Never import it from browser code.
import { mintReadToken } from "@xeonr/trail/server";
const token = await mintReadToken({
key: process.env.TRAIL_KEY!, // tk_<id>_<secret>
subject: user.urn,
filters: { "properties.namespace": ["ns-1"] }, // or filters().…build()
ttlSeconds: 900,
});Header { alg: "HS256", typ: "JWT", kid: <key id> }; claims
{ sub, iat, exp, trail: { read: { all?, filters?: [{field, op, values}], max_age_seconds? } } }.
The project comes from the key the kid resolves to, never the claim. A token
needs filters or all: true; an empty grant is refused so a bug dropping the
filters cannot widen it.
Writer
A batching, retrying in-memory queue in front of IngestService.WriteEvents,
for TypeScript services. Not the Go SDK's outbox: nothing survives the process.
import { Writer } from "@xeonr/trail/server";
const writer = new Writer({ ingest: trail.ingest, project: "pixelstream", onError: (err, events) => log.error(err, { dropped: events.length }) });
const id = writer.emit({ subTypeKey: "stream.started", outcome: Outcome.SUCCESS, actor: { kind: ActorKind.USER, urn }, target: { kind: "stream", id } });
await writer.close(); // on shutdownemit mints a ULID id (ingest's idempotency key) and occurredAt when the
caller brings neither; batches flush at 500 events or flushIntervalMs
(1000); transient failures retry with full-jitter backoff up to maxRetries
(5); a refusal or an exhausted batch goes to onError. ulid() is exported
from the root for anything else that needs one.
