@sdxc/server-timing
Server-Timing measurements collected per request and written to a response header
- Source
- packages/server-timing
Server-Timing measurements collected per request and written to a response header.
When a response is slow, the interesting question is which part was slow. The
Server-Timing
header answers it, and every browser's network panel shows the segments alongside the
transfer timings. A TimingCollector lives for one request, measure wraps each operation
worth timing, and toHeaders renders what it collected into the header's syntax.
Installation
npm add @sdxc/server-timing
Usage
Measure And Report
import { TimingCollector } from "@sdxc/server-timing";
let collector = new TimingCollector();
let user = await collector.measure("db", "findUserById", async () => {
return await findUserById(userId);
});
return new Response(JSON.stringify(user), { headers: collector.toHeaders() });
// Server-Timing: db;desc="findUserById";dur=12.34
Time Several Operations
Every measure call adds one entry, in the order the calls completed. Reuse the name to
group related operations and vary the description to tell them apart:
let collector = new TimingCollector();
let session = await collector.measure("auth", "authorize", () => authorize(request));
let cached = await collector.measure("cache", "lookup", () => cache.get(key));
let rows = await collector.measure("db", "listPosts", () => listPosts());
collector.toString();
// auth;desc="authorize";dur=4.10, cache;desc="lookup";dur=1.02, db;desc="listPosts";dur=8.77
The name is the grouping key you scan for in a network panel, so keep it short and stable and put the varying part in the description. Both halves reach every client that reads the response, which makes them names to choose as deliberately as any public API.
Annotate A Response On The Way Out
toHeaders writes into an existing Headers object, so a response that already exists
picks up the measurements taken while it was produced:
let response = await handle(request);
collector.toHeaders(response.headers);
return response;
API
new TimingCollector()
An empty, request-scoped set of measurements. Construct one per request, since a collector shared between requests reports one caller's timings in another caller's header.
collector.measure<T>(name: string, description: string, fn: () => Promise<T>): Promise<T>
Times fn and returns whatever it resolves to, unchanged. name groups the measurement
(db, cache, auth) and description says which particular operation it was.
let user = await collector.measure("db", "findUserById", () => findUserById(id));
The measurement is recorded whether fn resolves or rejects, and a rejection is re-thrown
untouched — a call that failed slowly is exactly the one worth seeing in the header.
collector.toString(): string
Renders every measurement as the header's value: the entries comma-separated, or an empty string when nothing was measured.
collector.toString(); // 'db;desc="findUserById";dur=12.34'
An entry carries dur once it has a duration above zero, and a duration is rounded to two
decimals. Some server runtimes advance the clock only across I/O, so timing an I/O call is
what produces a duration to read.
collector.toHeaders(headers?: Headers): Headers
Writes the measurements onto a Headers object as a single Server-Timing header and
returns that same object, so the call reads as an expression. A fresh Headers is created
when none is given.
return new Response(body, { headers: collector.toHeaders() });
It sets rather than appends, so the collector owns the header on whatever headers it is
given: a Server-Timing written upstream is replaced, and calling it twice on the same
object leaves one copy of the measurements.
Pattern: Timing A Whole Request
A wrapper around the handler gives every request a collector and stamps the header once the handler has answered, which is late enough to include the handler's own measurements:
import { TimingCollector } from "@sdxc/server-timing";
export function withTiming(
handler: (request: Request, timing: TimingCollector) => Promise<Response>,
) {
return async (request: Request) => {
let timing = new TimingCollector();
let response = await handler(request, timing);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: timing.toHeaders(new Headers(response.headers)),
});
};
}
Copying into a new Response keeps this working for the responses whose headers are
immutable, such as the one a redirect helper or an upstream fetch hands back. Where the
response is one you constructed yourself, timing.toHeaders(response.headers) is enough.
Pattern: Measuring Inside The Code That Does The Work
Passing the collector down lets a helper contribute its own entries to the same header, which is how a single response ends up attributing the work across the layers that did it:
import { TimingCollector } from "@sdxc/server-timing";
async function loadDashboard(userId: string, timing: TimingCollector) {
let user = await timing.measure("db", "findUserById", () => findUserById(userId));
let feed = await timing.measure("cache", "feed", () => cache.get(`feed:${userId}`));
return { user, feed };
}
let timing = new TimingCollector();
let data = await loadDashboard(userId, timing);
return Response.json(data, { headers: timing.toHeaders() });
// Server-Timing: db;desc="findUserById";dur=12.34, cache;desc="feed";dur=1.02
Timings nest the way the calls do: wrapping loadDashboard itself in a measure adds an
entry covering both, recorded after the two it contains.