@sdxc/logger
One wide event per Worker invocation, attached at the router and the job dispatcher
- Used by
- uptime, auth-saas, reader, blog, books
- Source
- packages/logger
One wide event per Worker invocation, attached at the router and the job dispatcher.
Fields are flat scalars under dotted keys, so every one of them is a filter in the log index.
The narrative lives in a capped notes array, read once a query has found the record.
Installation
npm add @sdxc/logger
It builds on remix v3, installed alongside it, and
the current-log accessor is AsyncLocalStorage, so a Worker enables nodejs_compat.
Two entry points:
@sdxc/logger—createLogger,Log,currentLog, and theLogger/Sampletypes. No router dependency.@sdxc/logger/middleware— thelog()middleware for aremix/routerrouter, and theCurrentLogkey it publishes under.
Usage
Configure once, attach at the router
import { createLogger } from "@sdxc/logger";
import { log } from "@sdxc/logger/middleware";
import { env } from "cloudflare:workers";
import { createRouter } from "remix/router";
export const logger = createLogger({ service: "api", version: env.CF_VERSION_METADATA?.id });
let router = createRouter({ middleware: [log(logger)] });
router.get("/teams/:teamId/members", async (ctx) => {
ctx.log.set({ team: { id: ctx.params.teamId } });
let members = await ctx.log.time("db", () => listMembers(ctx.params.teamId));
ctx.log.set({ members: { count: members.length } });
return Response.json(members);
});
That request emits one record, every field of it a filter:
{
"service": "api",
"kind": "request",
"route": "/teams/:teamId/members",
"http.method": "GET",
"http.status": 200,
"team.id": "team_…",
"db.count": 1,
"db.duration_ms": 31.4,
"members.count": 12,
"outcome": "ok",
"duration_ms": 84.2,
}
route is the pattern, never the path: each param's value is substituted back out of the
pathname once the handler ran. The URL, the headers and the colo are on the platform's own log.
Any other entry point
A queue consumer, a scheduled handler or a Durable Object opens its log directly:
async function consume(tasks: Task[]) {
let batch = logger.open("queue", { queue: { size: tasks.length } });
await batch.run(async () => {
for (let task of tasks) {
await batch.child("job", { job: { name: task.name } }).run(() => handle(task));
}
});
}
Each child emits a record of its own and adds to the batch's job.count. A router inside
that body joins the log already current, so one record carries both the entry point's fields
and the route.
From anywhere inside the invocation
import { currentLog } from "@sdxc/logger";
export async function readThrough(key: string) {
let hit = await cache.get(key);
currentLog()?.inc(hit ? "cache.hit" : "cache.miss");
return hit ?? compute(key);
}
currentLog() is undefined outside an invocation, so ?. costs nothing where none is open.
API
createLogger(options: Logger.Options): Logger
The worker's configuration, carried by every log opened through it or through a log() it
was handed to.
options.service: The worker's name, the same on every log so a query can group by it.options.environment: Whatever the bootstrap decides the environment is.options.version: The deployed version, from the platform's version metadata.options.sample: ASample.Options. Off unless given: every log is written.options.sink: Where records go. Defaults to the console; a test collects them instead.
Logger
logger.options is the configuration as given, and logger.open(kind, fields?) returns a
Log carrying it, for an entry point nothing else wraps.
Log
One invocation's record. new Log(options, fields?) builds one directly — a Logger.Options
plus the kind, which a test uses to hand a handler a log and read it back through its own
sink; everything else opens one through a Logger.
log.kind,log.outcome,log.parent: the invocation's kind,"ok"untilwarn()orfail()says otherwise, and the logchild()opened this one from.log.set(fields): Merges fields. One level of nesting flattens to dotted keys, so{ user: { id } }is stored asuser.id; anything deeper than a scalar is stored as its JSON, and anundefinedis skipped.log.inc(field, by?): Adds to a counter, creating it at zero;bydefaults to 1.log.time(name, fn)awaitsfnand adds to${name}.countand${name}.duration_mshowever it returns, rethrowing whatfnthrew.log.note(name, fields?): A breadcrumb, with its offset in milliseconds from when the log opened. Two hundred are kept; past thatnotes.droppedcounts the rest.log.warn()takes the same arguments and degrades the outcome — recorded and kept, without being an alarm.log.fail(error, fields?): Sets the outcome toerrorand recordserror.type,error.message, and — when the error carries them —error.codeanderror.retriable. The stack is attached when the record is written.log.child(kind, fields?): A log of another kind sharing this one's configuration. On emit it adds to this log's${kind}.countand degrades this log if it did not endok.log.run(fn): Binds this log as the current one forfn, fails it iffnthrows, emits it oncefnsettles, and returns whatfnreturned.log.emit(): Writes the record once —console.logforok,console.warnfordegraded,console.errorforerror— or drops it when the sampler says so. A second call does nothing, so emitting in afinallyis safe.
currentLog(): Log | undefined
The log of the invocation the call runs inside, or undefined outside one.
log(logger?: Logger) — from @sdxc/logger/middleware
Router middleware publishing the invocation's log as ctx.log, which it declares on
remix/router's request context. A log already current is joined, so a request served inside
another entry point's log produces one record; otherwise a request log opens, carrying
logger's configuration when one is given, and emits once the response is settled. Either way
it records route and http.method, and http.status when it opened the log itself. Omitted,
logger leaves the log without a service, the visible sign that the host running the router
has not wrapped its entry point with logger.open().run().
CurrentLog — from @sdxc/logger/middleware
The context key log() publishes under, read as ctx.get(CurrentLog) by code that does not
know the middleware chain in front of it.
Types
Log.Kind is "request" | "cron" | "queue" | "job" | "alarm" and Log.Outcome is
"ok" | "degraded" | "error". A Log.Value is string | number | boolean | null, Log.Fields
is a record of those accepting one level of nesting, Log.Sink is
(record, outcome) => void, and a Log.Note carries at, level, name and its own scalars.
namespace Sample {
interface Options {
/** Fraction of `ok` logs kept. Defaults to 1: everything. */
rate?: number;
/** An `ok` log at least this slow is kept whatever the rate. */
slowerThanMs?: number;
/** An `ok` log is kept whatever the rate when this returns true. */
keep?: (fields: Readonly<Record<string, Log.Value>>) => boolean;
}
}
A degraded or error log is always written; rate applies to ok logs only, and both
exemptions win over it.
Pattern: Shared Namespaces From Middleware
The fields a query starts from — who, for which tenant — are known by the middleware that resolves them, so set them there and handlers stop repeating it:
import type { Middleware } from "remix/router";
export function requireUser(): Middleware {
return async (ctx, next) => {
let user = await userFromSession(ctx.request);
if (!user) return new Response(null, { status: 401 });
ctx.log.set({ user: { id: user.id, plan: user.plan } });
return next();
};
}
Keep a namespace meaning the same thing in every worker — user, team, tenant, db,
fetch, cache, error, http, job — which is what makes a query across workers possible
at all. Never build a key out of a value: set({ team: { id } }) is team.id, while a key per
id grows the index by one field per value forever.
Pattern: Turning Sampling On For One Kind
Logging bills per event written, and width costs nothing, so the default keeps every log. When
volume approaches the allotment, keep confines the rate to the successful background runs,
where the events are:
export const logger = createLogger({
service: "api",
sample: { rate: 0.05, keep: ({ kind }) => kind !== "job" },
});
Every request is kept, every failed or retried job is kept, and one in twenty successful job runs is kept.