[ HTTP & responses ]
@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.
The Server-Timing
header shows which part of a response was slow, right in the browser's network panel. A
TimingCollector lives for one request, measure wraps each operation worth timing, and
toHeaders writes the header.
Installation
npm add @sdxc/server-timing
Usage
Measure and report
import { TimingCollector } from "@sdxc/server-timing";
let timing = new TimingCollector();
let user = await timing.measure("db", "findUserById", () => findUserById(userId));
return Response.json(user, { headers: timing.toHeaders() });
// Server-Timing: db;desc="findUserById";dur=12.34
Time several operations
Each measure call adds one entry, in the order the calls finish. Keep the name short and
stable (db, cache, auth) and put the varying part in the description. Both reach every
client that reads the response.
let timing = new TimingCollector();
await timing.measure("auth", "authorize", () => authorize(request));
await timing.measure("cache", "lookup", () => cache.get(key));
await timing.measure("db", "listPosts", () => listPosts());
await timing.measure("render", "", () => renderPage());
timing.toString();
// auth;desc="authorize";dur=4.10, cache;desc="lookup";dur=1.02, db;desc="listPosts";dur=8.77, render;dur=3.20
An empty description leaves desc out of the entry.
Annotate an existing response
toHeaders writes into the Headers it receives and returns them:
let response = new Response(html, { headers: { "Content-Type": "text/html" } });
timing.toHeaders(response.headers);
return response;
API
new TimingCollector()
An empty set of measurements for one request. Create one per request so each response reports only its own timings.
collector.measure<T>(name: string, description: string, fn: () => Promise<T>): Promise<T>
Times fn and returns what it resolves to. The entry is recorded whether fn resolves or
rejects, and a rejection is re-thrown unchanged.
collector.toString(): string
The header value: every entry comma-separated, or an empty string when nothing was measured.
An entry is name;desc="description";dur=ms, with the description quoted and escaped, the
duration rounded to two decimals, and dur present once the duration is above zero. Some
server runtimes advance the clock only across I/O, so CPU-only work can read as zero there.
collector.toHeaders(headers?: Headers): Headers
Sets Server-Timing on headers (a new Headers when omitted) and returns the same object.
It replaces any Server-Timing already present, so calling it twice leaves one copy.
Pattern: Timing every request
A wrapper gives each request a collector and writes the header after the handler answers.
Copying into a new Response also covers responses with immutable headers, such as one
returned by fetch or Response.redirect.
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)),
});
};
}
export default {
fetch: withTiming(async (request, timing) => {
let posts = await timing.measure("db", "listPosts", () => listPosts());
return Response.json(posts);
}),
};
Pattern: Measuring across layers
Pass the collector down so helpers add their own entries to the same header. A measure
around a helper adds an entry covering everything inside it, recorded after the inner ones.
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 timing.measure("load", "dashboard", () => loadDashboard(userId, timing));
return Response.json(data, { headers: timing.toHeaders() });
// Server-Timing: db;desc="findUserById";dur=12.34, cache;desc="feed";dur=1.02, load;desc="dashboard";dur=13.51
Pattern: Reporting a failed call
A rejected operation still produces its entry, so an error response can show how long the failing call took before it gave up.
import { TimingCollector } from "@sdxc/server-timing";
let timing = new TimingCollector();
try {
let rates = await timing.measure("upstream", "rates", () =>
fetch("https://api.example.com/rates").then((response) => response.json()),
);
return Response.json(rates, { headers: timing.toHeaders() });
} catch {
return new Response("Upstream unavailable", {
status: 502,
headers: timing.toHeaders(),
});
// Server-Timing: upstream;desc="rates";dur=5000.12
}