@aurascope-analytics/forwarder-app-hook
v0.2.0
Published
AuraScope Analytics app-hook forwarder — observes requests from inside a Node.js application and ships them off the request path
Readme
AuraScope app-hook forwarder
The app-hook forwarder is the replayable server-plane sender for request-lifecycle hooks in tenant applications. This package is a Node.js reference implementation for Next.js middleware and SvelteKit server hooks. Its core has no framework dependency; the framework boundary is a small structural request type.
The request path is fail open. observe() captures evidence into memory and returns
synchronously. It performs no file-system access, network access, or asynchronous wait.
The flush loop forms durable immutable batches and ships them away from the response
path.
Frozen wire contract
Each batch is sent to POST {AURASCOPE_INGEST_URL}/v1/logs/app-hook as newline-delimited
JSON (NDJSON), with at most 500 non-empty lines. It carries Authorization: Bearer
<forwarder secret>, Idempotency-Key: <persisted batch key>, and Content-Type:
application/x-ndjson.
Each line has exactly these fields:
| Field | Type | App-hook meaning |
| --- | --- | --- |
| v | integer | Always 1 |
| ts | string | Coordinated Universal Time observation timestamp in RFC 3339 form |
| site | string | Tenant site public key (pk_…) |
| iid | string | Random process instance identifier, captured once |
| n | integer | Per-process monotonic sequence, starting at 1 |
| method | string or null | Observed request method |
| path | string or null | Full request target; query string is untouched |
| status | integer or null | Always null because middleware is pre-response |
| ip_evidence | string or null | Complete raw configured header value, not a selected hop |
| ua | string or null | Raw user-agent, shipped byte-pristine (never mutated) |
There is no event_id or forwarder line field. Ingest derives event_id; the route
segment supplies the app-hook dialect. Ingest also resolves visitor Internet Protocol
(IP) evidence and applies the server-plane query allowlist. src/eventId.ts exists only
to reproduce ingest's committed golden vectors.
Configuration
loadConfigFromEnv() reads:
AURASCOPE_INGEST_URL— required ingest base URL.AURASCOPE_SITE_KEY— requiredpk_…public site key.AURASCOPE_FORWARDER_SK— requiredsk_…forwarder secret. It has no default and is never persisted or logged.AURASCOPE_QUEUE_DIR— durable queue root. Defaults to.aurascope/app-hook-queueunder the process working directory.AURASCOPE_BUFFER_MAX_EVENTS— optional explicit event-capacity override.AURASCOPE_WORST_CASE_RATE_PER_SEC— sizing input; defaults to 10.AURASCOPE_TARGET_OUTAGE_SEC— sizing input; defaults to 86,400 seconds.AURASCOPE_IP_EVIDENCE_HEADER— raw evidence header; defaults tox-forwarded-for.AURASCOPE_PROBE_MARKER— optional synthetic-sender self-declaration (ADR 0020). Sent as thex-aurascope-probe-markerbatch envelope header on every shipped batch — a versioned addition to the frozen v1 contract; event lines (includingua) are never mutated by it.
Every refusal names the variable it is about. The transposed pair — a pk_ in
AURASCOPE_FORWARDER_SK and an sk_ in AURASCOPE_SITE_KEY — is its own case and names
both variables and both prefixes in one message, because reporting only the first costs
the reader a second failed boot to discover the second.
Unless explicitly overridden, buffer capacity is computed as:
bufferMaxEvents = worstCaseRatePerSec × targetOutageSecSize both inputs from observed tenant peak rate and the outage window the tenant disk must retain. The default is a starting point, not a universal production measurement.
Next.js installation
The durable queue requires the Node.js runtime and writable persistent local storage. Do not deploy this implementation to an Edge runtime or ephemeral read-only file system.
This package publishes to the public npm registry as
@aurascope-analytics/forwarder-app-hook (ADR 0032), so a tenant consumes it as an
ordinary dependency:
npm install @aurascope-analytics/forwarder-app-hookThe published tarball is dist/ only — compiled JavaScript plus type declarations,
built by prepack on the way into every npm pack/npm publish. npm run build
(tsc) produces the same output locally and npm run typecheck is the same compiler
without emit; neither is a tenant step. The wordpress/ tree beside this package
ships through a different channel entirely and is excluded from the tarball by the
files whitelist.
The tenant-facing walkthrough is docs/public/install/forwarder-app-hook.md, and
scripts/check-app-hook-install.sh executes the tenant's real sequence on every change
to this forwarder — pack, install the tarball into a throwaway project, and import it by
bare specifier on plain Node.
Configure the environment at runtime, then adapt the framework response:
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { createAuraScopeMiddleware } from "@aurascope-analytics/forwarder-app-hook";
export const config = { runtime: "nodejs" };
const auraScope = createAuraScopeMiddleware();
export function middleware(request: NextRequest) {
auraScope(request);
return NextResponse.next();
}src/middleware.example.ts is the full documentation-as-code example. It is excluded
from this package's TypeScript build because next is intentionally not a dependency —
the tarball's dependency surface is a tenant-facing fact. It is still compiled: it is
type-checked against the real next types inside the throwaway project
scripts/check-app-hook-install.sh builds, where framework packages can be installed
without entering the tarball. The created middleware exposes .forwarder when
configuration and queue startup succeed, allowing application shutdown code to call
await forwarder.stop().
A missing or invalid required setting — or a queue directory that cannot be created —
makes the factory return a no-op middleware, and logs one line naming the reason before
it does. Fail open, not fail silent: the refusal messages above are tenant-facing copy
and this factory is the only path they can take, because both documented wirings call it
with no argument. Pass { logger } as the second argument to send that line somewhere
other than console.
Per-REQUEST capture and middleware errors are swallowed, with no log, so AuraScope never changes the tenant response and one malformed URL cannot flood their error output. The counters carry those instead.
SvelteKit installation
SvelteKit server hooks run during prerender as well as at request time. Guard creation
with building so the forwarder does not start its queue directory or shipper during a
build. A Fetch API Request already satisfies the middleware's accepted shape directly,
so no adapter object is needed:
import { building } from "$app/environment";
import type { Handle } from "@sveltejs/kit";
import { createAuraScopeMiddleware } from "@aurascope-analytics/forwarder-app-hook";
const auraScope = building ? undefined : createAuraScopeMiddleware();
export const handle: Handle = async ({ event, resolve }) => {
auraScope?.(event.request);
return resolve(event);
};src/hooks.server.example.ts is the documentation-as-code version of this wiring. Like
the Next.js example, it is excluded from the package's TypeScript build because its
framework imports are intentionally not dependencies of this package, and like it, the
packaged-install gate type-checks it against the real @sveltejs/kit types.
Route matching is a measurement boundary
A framework route matcher (Next.js config.matcher, SvelteKit route-level early
returns) narrows traffic one level above the forwarder: match every path, or accept
that unmatched paths are unobserved and their crawler history is never recoverable.
Raw-first storage only covers traffic the forwarder saw.
Retention and retries
Staging is cut into batches of no more than 500 lines. A formed batch's bytes and
idempotency key are immutable and persisted together. A restart recovers pending files
in ordinal order. 200 acknowledges and removes a batch, including duplicate rows;
401, 429, 503, other server failures, timeouts, and network errors retain it.
The circuit breaker pauses, retains, and resumes, honoring Retry-After and otherwise
using capped exponential backoff.
Each retained outcome moves exactly one counter, and the set covers the space: http401,
http429, http503, networkErrors for a send that never got an answer, and
httpOther for every remaining status the shipper retries — a 500 from ingest's own
dependency failure, a 502 from a proxy in between, a 404 from a mistyped
AURASCOPE_INGEST_URL — plus a 200 whose acknowledgement could not be parsed, which is
retry(200) because an unreadable acknowledgement cannot release a durable batch. Before
httpOther that whole class moved nothing at all, so a permanently wrong endpoint URL and
a healthy quiet site produced identical metrics.
A 400 — or a batch this process finds locally malformed and refuses to send — retains
the batch too, and since #378 also opens the breaker and increments badRequests. The
refusal is permanent for those bytes, so the alternative shapes are both worse: dropping
is the ack-and-move-on ADR 0018 forbids for a replayable sender, and the pre-#378 shape
re-offered the same rejected batch on every flush tick forever with no counter moving.
A wedged queue head is now a state (breakerState: "open") and a number rather than an
inference from a flat accepted.
Nothing is dropped merely because the breaker is open. At configured capacity
exhaustion only, fail-open wins: the queue drops the oldest data and increments the
local dropped metric. Persisted batches are immutable, so the implementation evicts
an entire oldest batch; staged events are evicted one oldest event at a time. This
keeps the loss window contiguous and preserves the newest resume edge. No drop signal
is added to the wire.
These behaviors implement ADR 0018, the durability acknowledgement rule in ADR 0004, and the frozen server-plane contract in ADR 0011.
Probe traffic is declared on the BATCH ENVELOPE, as the x-aurascope-probe-marker
header, because the frozen app-hook dialect has no dedicated marker field and mutating
ua would corrupt the evidence it carries. This is sender self-declaration, not
write-time classification, and is an explicit freeze-task input under
ADR 0020.
WordPress seam
The later WordPress host uses this exact ten-field contract, endpoint, immutable batch
identity, durable replay, and circuit-breaker semantics. Its host-specific queue and
off-response-path scheduling are outlined in wordpress/README.md.
Development
bunx tsc --noEmit
bun testRuntime code uses only Node.js standard-library modules and the Node 22 global fetch.
Development dependencies are limited to TypeScript and Node.js type declarations.
