Files
HealthLog/src/instrumentation.ts
T
Marc-André Bombeck d6d0f7e73f fix(http): read forwarding headers only from a connection a proxy could make
The client-IP resolver trusted one forwarding hop by default and read the
rightmost X-Forwarded-For entry. Next fills that header with the socket's
address only when the request carries none, so a caller reaching the
published port directly could send its own and choose a fresh address per
request: every per-IP limit (sign-in, second factor, passkey, share unlock,
webhooks) became per-request, and sessions, the audit log and sign-in
notices recorded the chosen address.

The server now writes each connection's own peer address into an internal
header from Node's http.server.request.start diagnostics channel, installed
at boot and overwriting anything a caller sent under that name. The resolver
reads forwarding headers only when that peer is loopback, private,
unique-local or CGNAT, where a reverse proxy sits; a public peer is its own
client address, and a log line says once that its forwarding headers were
ignored. With TRUST_PROXY_HOPS=0 the peer is used instead of no address, so
a directly exposed host no longer shares one bucket across all anonymous
callers. Proxies on the same host, Docker network or tailnet need no change.
2026-10-25 13:52:04 +01:00

304 lines
11 KiB
TypeScript

import type { Instrumentation } from "next";
/**
* The admin notice for a host whose login-IP lookups leave it. The geo
* resolver evaluates it on its first public-IP lookup; it holds a slot
* rather than importing the notification layer (see the notice module).
*/
async function registerOfflineGeoNoticeAtBoot(): Promise<void> {
try {
const { registerOfflineGeoNotice } =
await import("@/lib/notifications/offline-geo-notice");
registerOfflineGeoNotice();
} catch {
// Without it the lookups still resolve; only the notice is missing.
}
}
export async function register() {
// Only start the worker on the Node.js server runtime (not Edge, not build)
if (process.env.NEXT_RUNTIME === "nodejs") {
// First: every request then carries its socket's address (peer-address).
(await import("@/lib/http/peer-address")).installPeerAddressStamp();
// The event-loop monitor comes first, so the loading of the database
// client and of the worker graph is inside its view, and every stall it
// reports names the boot step that was running (`boot-steps.ts`).
const [{ startEventLoopLagMonitor }, { beginBootStep }] = await Promise.all(
[
import("@/lib/observability/event-loop-lag"),
import("@/lib/boot/boot-steps"),
],
);
startEventLoopLagMonitor();
// Every locale falls back to the English message catalog, so a process
// without a readable `messages/en.json` cannot serve a page or a job.
// Refuse to start with the file named, rather than fail every request.
const { assertDefaultCatalogReadable } =
await import("@/lib/i18n/server-catalogs");
assertDefaultCatalogReadable();
beginBootStep("readiness_summary");
// First-boot readiness summary: a human-readable block on the core secrets
// (with the exact fix on a bad one) before any fail-closed loader throws,
// plus a one-glance status of the optional channels. Runs for every process
// type (web + worker). Never throws — the real gate stays the loaders.
try {
const { logReadinessSummary } =
await import("@/lib/boot/readiness-summary");
logReadinessSummary();
} catch {
// A readiness summary must never break boot.
}
await registerOfflineGeoNoticeAtBoot();
// Does the configured encryption key open this database? A process whose
// key does not refuses every API request with one clear 503 and starts
// neither the producer nor the worker (a wrong key must never write).
beginBootStep("key_check");
try {
const [{ runBootKeyCheck }, { prisma }] = await Promise.all([
import("@/lib/boot/key-check"),
import("@/lib/db"),
]);
if (await runBootKeyCheck(prisma)) return;
} catch (err) {
console.error("[instrumentation] Encryption key check crashed:", err);
}
// Every connection is pinned to UTC; say clearly if one is not.
beginBootStep("time_zone_check");
try {
const [{ runBootTimeZoneCheck }, { prisma }] = await Promise.all([
import("@/lib/boot/time-zone-check"),
import("@/lib/db"),
]);
await runBootTimeZoneCheck(prisma);
} catch {
// A diagnostic must never break boot.
}
// Every pulse reader follows the operator's pulse day-mean method; mirror
// it in this process and keep the mirror current.
beginBootStep("pulse_method_sync");
try {
const { startPulseDayMeanMethodSync } =
await import("@/lib/measurements/pulse-day-mean-method");
await startPulseDayMeanMethodSync();
} catch {
// Without the mirror the process keeps the default method.
}
// Every Node process watches its heap as it watches its event loop: a
// trend line, and one report naming the work in flight before the heap
// runs out (a heap-limit crash logs nothing else).
beginBootStep("monitors");
try {
const [{ startHttpInFlightTracking }, { startHeapMonitor }] =
await Promise.all([
import("@/lib/observability/in-flight"),
import("@/lib/observability/heap-monitor"),
]);
startHttpInFlightTracking();
startHeapMonitor();
} catch {
// A diagnostic must never break boot.
}
// One line when the container's memory limit is below the supported
// floor. The app keeps running.
try {
const { logMemoryFloorAtBoot } = await import("@/lib/support/heap-limit");
logMemoryFloorAtBoot();
} catch {
// A diagnostic must never break boot.
}
// The background role starts after the server is up. Awaiting it here
// held every request, `/api/health` included, for the seconds pg-boss
// needs to start, provision its queues and run the boot discovery; the
// probe now answers `starting` meanwhile. A request that enqueues while
// the boss is not bound yet takes the same path as during a producer
// reconnect: the enqueue waits for the boss in the background
// (`enqueueWhenBossReady`, bounded and logged when it gives up), and a
// route that needs the worker answers 503 until it is up.
const [
{ shouldRunWorker },
{ markStartup, startupPhase, startupElapsedMs, STARTING_MAX_MS },
] = await Promise.all([
import("@/lib/process-type"),
import("@/lib/boot/startup-state"),
]);
markStartup("starting");
// One line when the start takes longer than any real boot; from then on
// the probe reports plain `degraded` instead of `starting`.
setTimeout(() => {
if (startupPhase() === "stalled") {
console.warn(
`[boot] The background role is still not up after ${Math.round(STARTING_MAX_MS / 1000)} s; /api/health now reports degraded without "starting".`,
);
}
}, STARTING_MAX_MS + 1_000).unref();
const ready = (up: boolean) => {
const elapsed = startupElapsedMs();
markStartup(up ? "ready" : "failed");
console.info(
`[boot] background role ${up ? "ready" : "failed"} after ${elapsed ?? 0} ms`,
);
};
if (!shouldRunWorker()) {
// Split deployment: web requests still produce queue jobs, but this
// process owns no schedules, supervision, or consumers.
void (async () => {
try {
beginBootStep("producer_start");
const { startGlobalBossProducer } =
await import("@/lib/jobs/boss-instance");
const boss = await startGlobalBossProducer(process.env.DATABASE_URL);
if (boss) {
const { registerShutdownHandlers } =
await import("@/lib/jobs/reminder/worker-lifecycle");
registerShutdownHandlers(boss);
}
ready(boss !== null);
const { reportBootSteps } = await import("@/lib/boot/boot-steps");
await reportBootSteps();
} catch (err) {
ready(false);
// Queue-backed garnishes degrade independently; never make the web
// server unavailable because its producer connection failed.
console.error(
"[instrumentation] Failed to start pg-boss producer:",
err,
);
}
})();
return;
}
void (async () => {
const { WideEventBuilder } = await import("@/lib/logging/event-builder");
const { emitIfSampled } = await import("@/lib/logging/transports");
// Emit startup event
const startupEvt = new WideEventBuilder("background");
startupEvt.setBackground({ task_name: "startup" });
try {
beginBootStep("worker_import");
const { startReminderWorker } =
await import("@/lib/jobs/reminder-worker");
await startReminderWorker();
ready(true);
startupEvt.addMeta("reminder_worker", "started");
startupEvt.finish();
emitIfSampled(startupEvt.toJSON());
} catch (err) {
ready(false);
startupEvt.setError(err);
startupEvt.addMeta("reminder_worker", "failed");
startupEvt.finish();
emitIfSampled(startupEvt.toJSON());
// Still log to stderr for container health checks
console.error(
"[instrumentation] CRITICAL: Failed to start reminder worker:",
err,
);
}
})();
}
}
export const onRequestError: Instrumentation.onRequestError = async (
error,
request,
context,
) => {
// Prisma-based settings lookup is not available in edge instrumentation.
if (process.env.NEXT_RUNTIME === "edge") return;
try {
const [{ getGlitchtipSettings }, { sendGlitchtipEvent }] =
await Promise.all([
import("@/lib/monitoring-settings"),
import("@/lib/monitoring/glitchtip"),
]);
const settings = await getGlitchtipSettings();
if (!settings.glitchtipEnabled || !settings.glitchtipDsn) return;
const err =
error instanceof Error
? error
: new Error(
typeof error === "string" ? error : "Unhandled request error",
);
// Skip expected errors from bot scanners (malformed JSON bodies)
if (err instanceof SyntaxError) return;
const userAgentHeader = request.headers["user-agent"];
const userAgent = Array.isArray(userAgentHeader)
? userAgentHeader[0]
: userAgentHeader;
const details = [
`Route: ${context.routePath}`,
`Type: ${context.routeType}`,
`Router: ${context.routerKind}`,
`Method: ${request.method}`,
`Path: ${request.path}`,
context.renderSource ? `Render: ${context.renderSource}` : null,
context.revalidateReason
? `Revalidate: ${context.revalidateReason}`
: null,
]
.filter(Boolean)
.join(" | ");
const message = `${err.message} [${details}]`.slice(0, 1900);
// Try to get request_id from Wide Event context
let requestId: string | undefined;
try {
const { getEvent } = await import("@/lib/logging/context");
requestId = getEvent()?.getRequestId();
} catch {
// Logging not available in this context
}
const delivery = await sendGlitchtipEvent({
dsn: settings.glitchtipDsn,
input: {
environment: settings.glitchtipEnvironment || "production",
message,
stack: err.stack,
level: "error",
type: err.name || "RequestError",
url: request.path,
userAgent,
sourceTag: "healthlog-server",
requestId,
},
});
if (!delivery.ok) {
console.error(
"Global Glitchtip request error reporting failed:",
delivery.status,
delivery.details,
);
}
} catch (reportError) {
console.error(
"Global Glitchtip request error reporting crashed:",
reportError,
);
}
};