Observability
A production application wants its navigations, its hydration, and its requests in the same traces its backend already emits. @zerolith/otel adds those three spans through @opentelemetry/api, the one dependency it has beyond zerolith, and nothing else: the tracer comes from the application, backed by whatever SDK and exporter it configured, and with the API's no-op tracer every call is free. The runtime packages stay dependency-free; the router only offers an observe option, which this package and any other tool can use.
Three spans
import { trace } from "@opentelemetry/api";
import { createClient } from "@zerolith/client";
import { navigationObserver, tracedTransport } from "@zerolith/otel";
import { createRouter } from "@zerolith/router";
import { type Api, routes as apiRoutes } from "./notes.api.ts";
const tracer = trace.getTracer("notes");
const router = createRouter(routes, { observe: navigationObserver(tracer) });
const api = createClient<Api>(apiRoutes, {
baseUrl: "https://notes.example",
transport: tracedTransport(fetch, tracer)
});
// A "navigate" span:
await router.navigate("/");
// A "GET" span with the URL in url.full, and a traceparent header:
await api.call("listNotes");| Function | Span | Attributes |
|---|---|---|
navigationObserver(tracer) |
navigate, from the start of a navigation to its commit, failure, or cancel |
url.path, url.query when the URL has one, and zerolith.navigation.mode; on commit, zerolith.route; on cancel, zerolith.navigation.canceled |
tracedTransport(transport, tracer) |
A client span per request, named by its method, as GET |
http.request.method, url.full, and http.response.status_code |
tracedHydrate(view, target, tracer) |
hydrate, around the call |
zerolith.hydration.mismatches, the number of mismatches |
A span ends with an OK status when its work succeeds and an error status when it fails. A loader, a hydration, or a request that throws also records its exception on the span, and a response outside 2xx sets the error status with its code. A canceled navigation leaves the status unset, since nothing failed. The tracer argument can be left out, and each function then uses trace.getTracer("@zerolith/otel") from the global tracer provider.
tracedTransport wraps the typed client's transport, or fetch when it is given undefined, and injects a traceparent header from the active span, so a server that reads W3C trace context continues the browser's trace. A client span is named by its method alone, as the HTTP conventions direct when no route template is known, since a name such as GET /notes/1 would give every note a span name of its own; the path is in url.full. url.full and url.query record the values of the query keys the URL conventions redact, sig, X-Amz-Signature, X-Amz-Credential, X-Amz-Security-Token, and X-Goog-Signature, as REDACTED, so a signed URL's signature never reaches the trace backend.
Wiring an SDK
The package never chooses an SDK. In the browser, OpenTelemetry's web SDK or a vendor's registers a tracer provider with the API, and from then on trace.getTracer("my-app") returns a real tracer; on the server, the Node SDK does the same for server rendering.
Without the package
The router's observe option is a plain function, so logging, analytics, or a custom metric can follow navigations the same way, without OpenTelemetry. Each event carries the navigation's id, the same for its start and its end, its url, and its mode, which is push, replace, or pop:
| Event | When | Also carries |
|---|---|---|
start |
A navigation begins | |
commit |
It has rendered | name, the matched route's name, or undefined when the fallback rendered |
error |
Its loader or a lazy import threw | error |
cancel |
A newer navigation replaced it before it committed |