import type { HeaderReader } from './routed-node.js'; /** * The middleware's context, taken STRUCTURALLY — kernel depends on no web framework, * not even for a type. The shape below is the subset of a Hono context this reads, so * `app.use('*', invocationLog())` type-checks inside a vertical without this package * knowing which framework that vertical chose. Same posture as `vertical-host` taking * its scope host as `VerticalScopeHost` rather than importing a concrete adapter: the * seam is the shape, not the vendor. * * It is also what puts this code in kernel rather than in `vertical-host`. Kernel owns * the router-assertion family (`readRoutedNode`, `assertPlatformCall`) — the code that * reads what the router asserted about a request — and this line is the same family * seen from the other end: it writes that assertion down so a reader can find it again. * Living here means a lean vertical picks it up without also taking on an AI SDK. */ export interface InvocationLogContext { req: { method: string; raw: { url: string; headers: HeaderReader; }; }; res?: { status: number; }; /** * The framework's per-request store (#1237). Hono's `Context` has one; the type is * structural and narrow on purpose, so it is declared optional — a caller that does * not provide it simply gets no invocation id on the scope's events, which reads as * unrecorded exactly like every other absent stamp. */ set?: (key: 'substratInvocationId', value: string) => void; /** The worker's bindings — read ONLY through the options below, never otherwise. */ env: Env; } /** * How this middleware verifies the router's assertion, in the vertical's own terms. * * Both are functions of the env rather than plain values because a Worker's bindings * arrive per-request, while `app.use(...)` runs once at module scope. They are the same * two knobs the vertical already passes to `readRoutedNode` in its `nodeFor`, and they * must be given the same answers: a log that trusts more than the router does is the * forged-tenant hole, and one that trusts less is silently empty. */ export interface InvocationLogOptions { /** * The router's shared secret as this worker holds it — `(env) => env.ROUTER_SECRET`. * * Omitting it is not a shortcut: with no secret to check against, an assertion can * only be accepted by `allowUnsigned`, so a bare `invocationLog()` in a deployed * vertical writes NOTHING. `pnpm lint:invocation-log` refuses that arrangement rather * than leaving a vertical to discover it from an empty log view. */ routerSecret?: (env: Env) => string | undefined; /** * The vertical's own `ALLOW_DEV_NODE` — an un-routed local instance behind a dev * router that holds no secret either. Anywhere else this must stay false, or the * header is a claim again. */ allowUnsigned?: (env: Env) => boolean; } /** * The shape of the emitted line. Deliberately a published contract rather than an * incidental object: the read proxy filters on these key names, and Workers Logs indexes * a `JSON.stringify`ed `console.log` as queryable TOP-LEVEL fields (`tenantId`, not * `$metadata.tenantId` nor `source.tenantId` — verified against the live telemetry API). * Renaming a key here silently empties a view, because a filter on a key that does not * exist returns `success: true` with zero events. Add fields; never rename one. */ export interface InvocationLogLine { /** Discriminator — what lets a reader tell this line from a vertical's own output. */ substrat: 'invocation'; tenantId: string; scopeId: string | null; vertical: string | null; /** * The K-26 surface that answered. Taken from the verified node, so an assertion that * named none reads as `readRoutedNode`'s documented default (`app`) rather than as a * null only this writer would produce — one representation across the platform. */ surface: string | null; method: string; /** Path ONLY — see `pathOf`. */ path: string; /** * #1237: the id every event this invocation emitted is stamped with. * * The join nothing could make before. `$metadata.requestId` correlates the LINES of * one invocation, and it is stamped by the log platform at ingestion — no code here * can read it, and the spine could not have been given it. So the platform mints its * own, writes it here, and carries it to the scope on `InvokeOptions`. * * Which makes this line the other half of a trace: the events say what happened and * in what order, and this says how long the whole call took and how it ended. */ invocationId: string; /** * The response status as the caller received it — including the status `onError` * mapped a thrown error to. * * Hono composes `onError` INSIDE the handler chain, not around it, so a handler that * throws does not reject this middleware's `await next()`: the envelope has already * turned it into a response by the time control comes back, and `c.res` holds the * mapped status. That is worth stating because the opposite is the natural guess, and * guessing it would have put `null` on every error line — the exact rows a tenant * opens this view to find. * * `null` therefore survives only for a genuine escape: an error that got past the * envelope itself, which `threw` marks. */ status: number | null; /** The error escaped even `onError` — rare, and the most interesting line on the page. */ threw: boolean; durationMs: number; } /** * Mount as the FIRST middleware on a vertical's app, with the same two answers the * vertical gives `readRoutedNode` in its own `nodeFor`: * * ```ts * const app = new Hono<{ Bindings: Env }>(); * app.use( * '*', * invocationLog({ * routerSecret: (env) => env.ROUTER_SECRET, * allowUnsigned: (env) => env.ALLOW_DEV_NODE === 'true', * }), * ); * ``` * * First, because Hono composes handlers in registration order and stops at the one that * returns a response — middleware registered after a route does not wrap that route. A * vertical that mounts this below its own routes gets a log for some of its surface and * silence for the rest, which is worse than none, because the silence reads as no * traffic. `pnpm lint:invocation-log` refuses that arrangement rather than trusting * anyone to remember it. * * Nothing here can fail a request: the line is written in a `finally`, and a throw from * the handler is re-thrown untouched for `onError` to map as it always did. */ export declare function invocationLog(options?: InvocationLogOptions): (c: InvocationLogContext, next: () => Promise) => Promise; //# sourceMappingURL=invocation-log.d.ts.map