[]previous and next page

Observability

Three tools tell you what a request cost, in production and in development.

For agentsUpdated

Three tools tell you what a request cost, in production and in development. They are separate and cost nothing when unused: the code behind them is a chunk the handler loads only when one is configured (a tracer, the serverTiming option, the metrics option, or development mode), and a handler with none of them reads no clock and creates no span.

Tool Where What it gives
Server-Timing header serverTiming option, event.timing total, route, loaders, render and your own metrics, in the response, for the browser's network panel.
Child spans tracer of src/instrumentation.ts A span per loader, per endpoint and for the render, with OpenTelemetry attribute names.
Metrics metrics option A request counter and a duration histogram by route, method and status class.

A bad serverTiming or metrics value does not stop the server: a TypeError goes to the error sink (phase instrumentation) right after the handler is created, and the app runs without observability. Everything else here fails the same way: a tracer, a meter or a hook that throws is reported and the request is answered.

The Server-Timing header#

serverTiming is true, false or { header, onTimings }. Left out, it is on in development and off in production: the numbers describe your server's internals, so a production site turns it on on purpose. The header looks like this (durations are milliseconds from performance.now()):

text
Server-Timing: total;dur=12.3, route;desc="/posts/:id";dur=0.4, loaders;dur=8.1, render;dur=2.2, db;desc="load post";dur=7.6
Entry Meaning
total Time from the start of the request to the response head.
route Time until the route was resolved. The description is the route pattern; unmatched when no route matched.
loaders The route loader chain of a page (the wall time of all the loaders together).
render The render of a page, up to the shell.
your names The metrics you record with event.timing.

A response that already has its own Server-Timing header (set by a route, a middleware or a header rule) is left alone. For a streamed page the head is sent when the shell is ready, so total and render are the time to the shell; the time until the last chunk is in the dusk.page.stream span and in the onTimings hook below.

Record your own metrics with event.timing.measure(name, fn, description?), which returns what fn returns (a promise is measured until it settles), or event.timing.add(name, durationMs, description?). In code that has no event at hand, timing(name, fn) from @shadow-js/dusk/server does the same for the request that is running. A name is a token (letters, digits and !#$%&'*+-.^_`|~) of at most 64 characters, and not total, route, loaders or render; a request records at most 20 distinct names, and measuring a name again adds to its total. An invalid name throws a TypeError, so a name taken from user input cannot reach a header. A description is reduced to printable ASCII (a quote, a backslash, a comma, a semicolon or a line break becomes _) and cut at 100 characters. With serverTiming off, both methods only run your function.

onTimings(timings) is called once when the request is over: for a streamed page when the body ends, for a request the client abandoned when it does. It receives the request id, method, path, route pattern, status, the durations (totalMs, routeMs, loadersMs, renderMs, streamMs), aborted and your metrics. streamMs is the full render time of a streamed page. { header: false, onTimings } records without putting the numbers in responses, which is the way to feed a log or a dashboard from production.

Child spans#

When src/instrumentation.ts exports a tracer, the request span dusk.request gets child spans, all ended exactly once, also when the client disconnects or the request times out (a span the request outlived is ended with the status error and error.type set to aborted, timeout or abandoned):

Span Covers Attributes
dusk.request The whole request, up to the response head. http.request.method, url.path; at the end http.response.status_code, http.route (when a route matched), error.type (a 5xx).
dusk.page, dusk.api, dusk.rpc, dusk.stream, dusk.image, dusk.nav, dusk.draft The endpoint that answered, after the middleware. http.route; dusk.route.id (dusk.rpc.name for a server function, with the function id); http.response.status_code.
dusk.loaders The loader chain of a page. none
dusk.loader One route's loader. dusk.route.id
dusk.render The render of a page, up to the shell. none
dusk.page.stream A streamed page, from the shell to the last chunk. It may end after dusk.request. none
dusk.timing One event.timing.measure call. dusk.timing.name

A span whose work throws records the exception (recordException), sets error.type to the error's name and the status to error. A redirect, a not-found and an error below 500 are answers, not failures: they end their span without an exception. An error the framework reports for the request is also recorded on dusk.request. A tracer that throws is reported once and spans stop for the rest of the process; requests are not affected.

Metrics#

metrics takes the subset of the OpenTelemetry Metrics Meter that Dusk uses: createCounter(name, options).add(value, attributes) and createHistogram(name, options).record(value, attributes). Pass metrics.getMeter("my-app") from @opentelemetry/api, or any object with those two methods. Dusk creates two instruments when the handler starts:

Instrument Records
dusk.server.requests (counter, {request}) 1 per request.
http.server.request.duration (histogram, s) Seconds from the start of the request until the response body ended.

Both carry http.request.method (_OTHER for a method outside GET, HEAD, POST, PUT, DELETE, PATCH and OPTIONS), http.response.status_class (2xx, 4xx, 5xx) and, when a route matched, http.route (the pattern, so the label has as many values as the app has routes). A streamed page is recorded when its body ends.

OpenTelemetry in src/instrumentation.ts#

This is a complete setup with the OpenTelemetry Node SDK. The @opentelemetry/* packages are yours to install (npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-metrics @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http); Dusk depends on none of them. register() starts the SDK once when the server starts. Dusk reads tracer from this module and records exceptions on the spans itself. The meter goes to the metrics option of the entry's options, in src/entry-server.ts: export default { serverTiming: { header: false }, metrics: meter } satisfies Omit<HandlerOptions, "manifest"> with meter imported from ./instrumentation (header: false keeps the numbers out of responses and still feeds onTimings, the spans and the meter).

src/instrumentation.tsts
import { metrics, trace } from "@opentelemetry/api";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { NodeSDK } from "@opentelemetry/sdk-node";

const sdk = new NodeSDK({
  serviceName: "my-app",
  traceExporter: new OTLPTraceExporter(),
  metricReader: new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter() }),
});

export function register(): void {
  sdk.start();
  process.once("SIGTERM", () => void sdk.shutdown());
}

// The API returns proxies that use the provider the SDK installs, so these exports can be created before `register()` runs.
export const tracer = trace.getTracer("my-app");
export const meter = metrics.getMeter("my-app");

Turn Server-Timing into a response header only where you want it: serverTiming: true in production lets anyone who can load a page read your server's timings, which is useful behind a trusted proxy and a leak otherwise.