Answer stability round 1: 2 🔴, 3 🟡 (0 misread); refuse a trailing-slash range, full plugin route, merged tests, compose passes TRUSTED_PROXIES

This commit is contained in:
2026-11-08 15:06:19 +01:00
parent 85219b081a
commit b19b43685c
10 changed files with 84 additions and 56 deletions
+11 -10
View File
@@ -12,18 +12,19 @@ The host's contract version is now 0.5.0, so discovery refuses a plugin whose `a
`0.4.x`. Change the literal in each plugin's manifest when you upgrade, and add `assetHref` to any `0.4.x`. Change the literal in each plugin's manifest when you upgrade, and add `assetHref` to any
`RequestContext` your tests build by hand: `assetHref: (href) => href`. `RequestContext` your tests build by hand: `assetHref: (href) => href`.
### Every request's span and `request` log line name the caller, the site and the route ### The `request` log line renames `method`, `path`, `status` and `ms`, and gains the caller, site and route
The `request` line and the request span now carry OpenTelemetry's HTTP attributes: Update dashboards and log queries: the `request` line and the request span now use OpenTelemetry's
`http.request.method`, `url.path`, `http.response.status_code`, `http.route` (when a route matched), HTTP names. `method`, `path` and `status` become `http.request.method`, `url.path` and
`server.address` (the `Host`), `user_agent.original` and `client.address`. They replace `method`, `http.response.status_code`. `ms` is gone: latency is the span's duration, so it needs
`path` and `status`; `ms` is gone, because the span's own duration is the latency. Plugins' own log `OTLP_ENDPOINT`. New are `http.route` (when a route matched), `server.address` (the `Host`),
lines for a request carry them too. `user_agent.original` and `client.address`. A plugin's own lines for a request carry them too,
except the status.
`client.address` is the socket's peer. Set `TRUSTED_PROXIES` (comma-separated IPs or CIDR ranges) `client.address` is the socket's peer. Set `TRUSTED_PROXIES` (comma-separated IPs or CIDR ranges) to
to the proxies in front of the host and the address `X-Forwarded-For` names is used instead. A bad the proxies in front of the host and it follows `X-Forwarded-For`; a bad entry refuses the boot.
entry refuses the boot. The log now holds callers' IPs and user agents, which are personal data: README → Observability says which ranges to list. The log now holds callers' IPs and user agents,
say so in your privacy policy. which are personal data: say so in your privacy policy.
### `buildPaginationModel` builds the pagination partial's model ### `buildPaginationModel` builds the pagination partial's model
+25 -11
View File
@@ -1237,8 +1237,8 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
| `LOG_FORMAT` | `text` | log line format: `text` (human-readable, dev) or `json` (structured, prod) | | `LOG_FORMAT` | `text` | log line format: `text` (human-readable, dev) or `json` (structured, prod) |
| `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment | | `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment |
| `OTLP_ENDPOINT` | _unset_ | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) | | `OTLP_ENDPOINT` | _unset_ | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) |
| `TRUSTED_PROXIES` | _unset_ | comma-separated IPs/CIDR ranges of the reverse proxies in front of the host; only a request from one lets `X-Forwarded-For` name the caller in logs and spans ([Observability](#observability)) |
| `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` | | `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` |
| `TRUSTED_PROXIES` | _unset_ | comma-separated IPs/CIDR ranges of the reverse proxies in front of the host; only a request from one lets `X-Forwarded-For` name the caller in logs and spans ([Observability](#observability)) |
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) | | `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | authorization check / write | | `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | authorization check / write |
| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) | | `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) |
@@ -1763,15 +1763,29 @@ Logging is **structured** and **OTLP-native**, on
handler (an `AsyncLocalStorage`), so logs and traces correlate. Three explicit toggles: handler (an `AsyncLocalStorage`), so logs and traces correlate. Three explicit toggles:
`LOG_LEVEL`, `LOG_FORMAT` (`text` dev / `json` prod) and `SERVICE_NAME`. `LOG_LEVEL`, `LOG_FORMAT` (`text` dev / `json` prod) and `SERVICE_NAME`.
Every request emits one access line, and the same fields ride its span. They follow OpenTelemetry's Every request emits one access line, and its span carries the same fields, named by
HTTP conventions: `http.request.method`, `url.path` (the query is dropped, it can carry tokens), OpenTelemetry's HTTP conventions:
`http.response.status_code`, `http.route` (the matched pattern, absent on a 404), `server.address`
(the `Host`), `user_agent.original`, `client.address` and `requestId`; the span's duration is the - `http.request.method`, `url.path` (the query is dropped, it can carry tokens),
latency. **They are personal data** (an IP and a user agent identify a caller), so a deployment's `http.response.status_code`, `server.address` (the `Host`), `user_agent.original`,
privacy policy must say the host logs them. `client.address` is the socket's peer; set `client.address` and `requestId`.
`TRUSTED_PROXIES` to the IPs or CIDR ranges of the proxies in front of the host and a request from - `http.route`: the matched pattern, such as `/admin/users/:id`. It is absent when no route
one takes the nearest address in `X-Forwarded-For` that is not itself a listed proxy. Never list a matched (a 404, a 405, a `/public/` file, a redirect) and on a plugin's `onRequest` short-circuit.
range a caller can send from: the header is then theirs to write. - Latency is the span's duration, so it reaches you only with `OTLP_ENDPOINT` set. OTLP carries every
attribute as a string.
A plugin's own lines for a request carry them too, except the status, which is known only when
the response closes.
**These are personal data.** An IP and a user agent identify a caller, so a deployment's privacy
policy must say the host logs them.
`client.address` is the socket's peer. Behind a reverse proxy that is the proxy, so set
`TRUSTED_PROXIES` to its IPs or CIDR ranges: for a request from a listed peer, the address is the
nearest one in `X-Forwarded-For` that is not itself listed. Anything else in the header is
ignored. List only addresses a caller cannot send from. In Docker, `compose.yml` publishes the port
on every interface, so a range that includes the bridge gateway lets a direct caller write any
`client.address`.
Login/logout, admin writes and missing-permission/CSRF rejections log at `info`/`warn`, the Login/logout, admin writes and missing-permission/CSRF rejections log at `info`/`warn`, the
catch-all 500 and Ory-unreachable re-mint at `error`/`warn`. An inbound W3C catch-all 500 and Ory-unreachable re-mint at `error`/`warn`. An inbound W3C
@@ -1860,7 +1874,7 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
server.ts Entry point; config.ts env loader; logger.ts structured log + trace span + tracedFetch server.ts Entry point; config.ts env loader; logger.ts structured log + trace span + tracedFetch
*.test.ts Guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate/function-limits) *.test.ts Guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate/function-limits)
http/ Request pipeline: app.ts (createApp), builtin-route, context, body, cookie, http/ Request pipeline: app.ts (createApp), builtin-route, context, body, cookie,
static, safe-url (safeUrl + localPath), security-headers, request-fields (span/log attributes) static, safe-url (safeUrl + localPath), security-headers, request-fields · request-tracking (span/log attributes)
auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) · auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) ·
login · guards · csrf · denylist · flow-view · oauth-login · oauth-consent · login · guards · csrf · denylist · flow-view · oauth-login · oauth-consent ·
routes · bootstrap, and the Ory REST clients (kratos-public/admin, keto, hydra, routes · bootstrap, and the Ory REST clients (kratos-public/admin, keto, hydra,
+1
View File
@@ -24,6 +24,7 @@ services:
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-} PLUGIN_DB_URL: ${PLUGIN_DB_URL:-}
REQUIRE_SECURE_SECRETS: "true" REQUIRE_SECURE_SECRETS: "true"
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} # IPs/CIDRs of your reverse proxy; empty ⇒ X-Forwarded-For is ignored
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/ # Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
# consent handler) + the one-shot bootstrap (admin + JWKS seed). Postgres too: a plugin that # consent handler) + the one-shot bootstrap (admin + JWKS seed). Postgres too: a plugin that
# declares `storage` opens its connection in onBoot, before the server listens. # declares `storage` opens its connection in onBoot, before the server listens.
+1 -1
View File
@@ -185,7 +185,7 @@ export function loadConfig(env: Env = process.env): Config {
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"), ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"), kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"), kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
// Console-only by default; OTLP_ENDPOINT exports logs + per-request spans to an OpenTelemetry Collector. // Console-only unless OTLP_ENDPOINT names a collector.
logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"), logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"),
logLevel: readEnum(env, "LOG_LEVEL", LOG_LEVELS, "info"), logLevel: readEnum(env, "LOG_LEVEL", LOG_LEVELS, "info"),
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5), oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
+19 -2
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { test, type TestContext } from "node:test"; import { test, type TestContext } from "node:test";
import { createLogger } from "../logger.ts"; import { createLogger } from "../logger.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import { createApp } from "./app.ts"; import { createApp } from "./app.ts";
// The `request` line, parsed, once the server has logged it (it logs on the response's close). // The `request` line, parsed, once the server has logged it (it logs on the response's close).
@@ -14,10 +15,10 @@ async function requestLineOf(lines: string[]): Promise<Record<string, unknown>>
throw new Error("no request line was logged"); throw new Error("no request line was logged");
} }
async function serve(t: TestContext, trustedProxies: string[]): Promise<{ lines: string[]; url: string }> { async function serve(t: TestContext, trustedProxies: string[], plugins: Plugin[] = []): Promise<{ lines: string[]; url: string }> {
const lines: string[] = []; const lines: string[] = [];
const log = createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (l: string) => lines.push(l) }); const log = createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (l: string) => lines.push(l) });
const app = createApp({ log, trustedProxies }); const app = createApp({ log, plugins, trustedProxies });
await new Promise<void>((r) => app.listen(0, r)); await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close()); t.after(() => app.close());
return { lines, url: `http://localhost:${(app.address() as AddressInfo).port}` }; return { lines, url: `http://localhost:${(app.address() as AddressInfo).port}` };
@@ -35,9 +36,25 @@ test("the request line names the caller, the site and the matched route in OpenT
assert.equal(line["server.address"], "localhost"); assert.equal(line["server.address"], "localhost");
assert.equal(line["user_agent.original"], "TV/1.0"); assert.equal(line["user_agent.original"], "TV/1.0");
assert.match(String(line["client.address"]), /^(127\.0\.0\.1|::1)$/); // the forged header is not believed assert.match(String(line["client.address"]), /^(127\.0\.0\.1|::1)$/); // the forged header is not believed
assert.equal(line["service.name"], "plainpages");
assert.ok(line["requestId"], "carries a requestId for log↔trace correlation");
for (const old of ["method", "path", "status", "ms"]) assert.equal(line[old], undefined, old); for (const old of ["method", "path", "status", "ms"]) assert.equal(line[old], undefined, old);
}); });
test("http.route is the full pattern, plugin prefix included, so two plugins' routes never merge", async (t) => {
const plugin: Plugin = {
apiVersion: "1.0.0",
id: "demo",
routes: [{ handler: () => ({ json: {} }), method: "GET", path: "/items/:id", public: true }],
};
const { lines, url } = await serve(t, [], [plugin]);
await (await fetch(`${url}/demo/items/7`)).text();
const line = await requestLineOf(lines);
assert.equal(line["http.route"], "/demo/items/:id");
assert.equal(line["url.path"], "/demo/items/7");
});
test("a listed proxy lets X-Forwarded-For name the caller; an unmatched path carries no route", async (t) => { test("a listed proxy lets X-Forwarded-For name the caller; an unmatched path carries no route", async (t) => {
const { lines, url } = await serve(t, ["127.0.0.0/8", "::1"]); const { lines, url } = await serve(t, ["127.0.0.0/8", "::1"]);
await (await fetch(`${url}/no-such-page`, { headers: { "x-forwarded-for": "198.51.100.7" } })).text(); await (await fetch(`${url}/no-such-page`, { headers: { "x-forwarded-for": "198.51.100.7" } })).text();
-22
View File
@@ -115,28 +115,6 @@ test("renders branding from the menu config into the shell: logo + default theme
assert.match(html, /id="theme-dark"\s+checked/); // config default theme reaches the switch assert.match(html, /id="theme-dark"\s+checked/); // config default theme reaches the switch
}); });
test("emits a structured access-log line per request (the injected logger)", async (t) => {
const lines: string[] = [];
const app = createApp({ log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/?q=zz`); // the public "/" — no auth
assert.equal(res.status, 200);
await res.text(); // consume the body so the connection closes (the access line emits on close)
// The line is emitted on connection close (after the body is sent) — poll briefly for it.
let line: string | undefined;
for (let i = 0; i < 50 && !line; i++) {
line = lines.find((l) => l.includes('"msg":"request"'));
if (!line) await new Promise((r) => setTimeout(r, 10));
}
assert.ok(line, "an access line is logged for the request");
const rec = JSON.parse(line!);
assert.equal(rec["url.path"], "/"); // pathname only — the ?q=… query is dropped (may carry tokens)
assert.equal(rec["service.name"], "plainpages");
assert.ok(rec.requestId, "carries a requestId for log↔trace correlation");
});
test("ctx.log: a handler logs in the request trace, and ctx.log.fetch continues the inbound trace", async (t) => { test("ctx.log: a handler logs in the request trace, and ctx.log.fetch continues the inbound trace", async (t) => {
const lines: string[] = []; const lines: string[] = [];
const upstream: { traceparent: string | undefined; url: string }[] = []; const upstream: { traceparent: string | undefined; url: string }[] = [];
+2 -2
View File
@@ -24,7 +24,7 @@ import type { KratosPublic } from "../auth/kratos-public.ts";
import { createLogger, type Log } from "../logger.ts"; import { createLogger, type Log } from "../logger.ts";
import { remintSession } from "../auth/login.ts"; import { remintSession } from "../auth/login.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts"; import { declaredPermissions, fullPath, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
import type { PluginSettings } from "../plugin-host/settings.ts"; import type { PluginSettings } from "../plugin-host/settings.ts";
import type { SystemCapabilities } from "../plugin-host/system.ts"; import type { SystemCapabilities } from "../plugin-host/system.ts";
import { allows, type Gate } from "../auth/gate.ts"; import { allows, type Gate } from "../auth/gate.ts";
@@ -295,7 +295,7 @@ export function createApp(options: AppOptions = {}): Server {
const match = matchRoute(plugins, method, pathname); const match = matchRoute(plugins, method, pathname);
if (match) { if (match) {
reqLog.context["http.route"] = match.route.path; reqLog.context["http.route"] = fullPath(match.plugin.id, match.route.path);
const routeCtx = contextFor(match.plugin.id, match.params); const routeCtx = contextFor(match.plugin.id, match.params);
if (!allows(match.route, routeCtx.user)) { if (!allows(match.route, routeCtx.user)) {
await refuse(match.route, routeCtx); await refuse(match.route, routeCtx);
+15 -1
View File
@@ -26,8 +26,22 @@ test("clientAddress: a malformed hop or a mapped IPv4 peer never yields garbage"
assert.equal(clientAddress("fd00::1", "2001:db8::7", proxies), "2001:db8::7"); assert.equal(clientAddress("fd00::1", "2001:db8::7", proxies), "2001:db8::7");
}); });
test("parseTrustedProxies: a range covers exactly its prefix, and the extremes are legal", () => {
const one = parseTrustedProxies(["10.1.2.3/32", "fd00::7/128", "192.168.0.0/16"]);
assert.equal(clientAddress("10.1.2.3", "198.51.100.1", one), "198.51.100.1");
assert.equal(clientAddress("10.1.2.4", "198.51.100.1", one), "10.1.2.4"); // /32 is one address
assert.equal(clientAddress("fd00::8", "2001:db8::7", one), "fd00::8"); // /128 is one address
assert.equal(clientAddress("192.168.255.1", "198.51.100.1", one), "198.51.100.1");
assert.equal(clientAddress("192.169.0.1", "198.51.100.1", one), "192.169.0.1");
assert.equal(clientAddress("203.0.113.9", "198.51.100.1", parseTrustedProxies(["0.0.0.0/0"])), "198.51.100.1"); // /0 is a choice, not a typo
});
test("clientAddress: a malformed hop left of a trusted one is not believed over the peer", () => {
assert.equal(clientAddress("10.0.0.1", "garbage, 10.0.0.2", proxies), "10.0.0.1");
});
test("parseTrustedProxies: refuses an entry that is not an IP or a CIDR range", () => { test("parseTrustedProxies: refuses an entry that is not an IP or a CIDR range", () => {
for (const bad of ["proxy.internal", "10.0.0.0/33", "10.0.0.0/x", "::1/129", ""]) { for (const bad of ["proxy.internal", "10.0.0.0/33", "10.0.0.0/x", "::1/129", "", "10.0.0.1/", "10.0.0.0/ ", "fd00::1/", "10.0.0.0//8", "10.0.0.0/8/8", "10.0.0.0/-1", "10.0.0.0/1e1"]) {
assert.throws(() => parseTrustedProxies([bad]), /TRUSTED_PROXIES/, bad); assert.throws(() => parseTrustedProxies([bad]), /TRUSTED_PROXIES/, bad);
} }
}); });
+5 -3
View File
@@ -3,8 +3,8 @@ import { BlockList, isIP } from "node:net";
export type TrustedProxies = BlockList; export type TrustedProxies = BlockList;
// Each entry is an IP address or a CIDR range (`10.0.0.0/8`). Fails loud: a typo here would // Each entry is an IP address or a CIDR range (`10.0.0.0/8`). A bad entry refuses the boot: a
// silently log a proxy's address for every caller, or trust a stranger's header. // misread one could trust every caller's X-Forwarded-For.
export function parseTrustedProxies(entries: readonly string[] = []): TrustedProxies { export function parseTrustedProxies(entries: readonly string[] = []): TrustedProxies {
const list = new BlockList(); const list = new BlockList();
for (const entry of entries) { for (const entry of entries) {
@@ -12,7 +12,9 @@ export function parseTrustedProxies(entries: readonly string[] = []): TrustedPro
const family = isIP(address); const family = isIP(address);
const length = prefix === undefined ? undefined : Number(prefix); const length = prefix === undefined ? undefined : Number(prefix);
const maxLength = family === 4 ? 32 : 128; const maxLength = family === 4 ? 32 : 128;
if (family === 0 || rest.length > 0 || (length !== undefined && !(Number.isInteger(length) && length >= 0 && length <= maxLength))) { // `Number("")` is 0, so a trailing slash must be refused before the range check sees it.
const badPrefix = prefix !== undefined && !(/^\d{1,3}$/.test(prefix) && length! <= maxLength);
if (family === 0 || rest.length > 0 || badPrefix) {
throw new Error(`config: TRUSTED_PROXIES entry "${entry}" is not an IP address or CIDR range`); throw new Error(`config: TRUSTED_PROXIES entry "${entry}" is not an IP address or CIDR range`);
} }
const kind = family === 4 ? "ipv4" : "ipv6"; const kind = family === 4 ? "ipv4" : "ipv6";
+5 -4
View File
@@ -4,7 +4,8 @@ import { type Log, requestLogger, runWithLog } from "../logger.ts";
import { requestFields, type TrustedProxies } from "./request-fields.ts"; import { requestFields, type TrustedProxies } from "./request-fields.ts";
// Gives one request its own logger and span, runs `handle` under it, and writes the `request` line // Gives one request its own logger and span, runs `handle` under it, and writes the `request` line
// when the response closes. The handler adds `http.route` to the logger's context once it knows it. // when the response closes. The handler adds `http.route` to the logger's context once routing has
// matched.
export function trackRequest( export function trackRequest(
log: Log, log: Log,
trusted: TrustedProxies, trusted: TrustedProxies,
@@ -12,8 +13,6 @@ export function trackRequest(
res: ServerResponse, res: ServerResponse,
handle: (req: IncomingMessage, res: ServerResponse, reqLog: Log) => Promise<void>, handle: (req: IncomingMessage, res: ServerResponse, reqLog: Log) => Promise<void>,
): void { ): void {
// "close" (not "finish") fires on both a completed response and a premature disconnect, so an
// aborted request is still logged and its span flushed.
const reqLog = requestLogger(log, { const reqLog = requestLogger(log, {
attributes: requestFields(req, trusted), attributes: requestFields(req, trusted),
requestId: randomUUID(), requestId: randomUUID(),
@@ -25,10 +24,12 @@ export function trackRequest(
let settled = false; let settled = false;
let closed = false; let closed = false;
const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); }; const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
// "close" (not "finish") fires on both a completed response and a premature disconnect, so an
// aborted request is still logged and its span flushed.
res.on("close", () => { res.on("close", () => {
closed = true; closed = true;
try { try {
// The context is what the span exports, so the status goes there; the span's duration is the latency. // The context is what the span exports, so the status goes there.
reqLog.context["http.response.status_code"] = res.statusCode; reqLog.context["http.response.status_code"] = res.statusCode;
reqLog.info("request"); reqLog.info("request");
} catch { /* never let logging crash a served request */ } } catch { /* never let logging crash a served request */ }