corpus

Log | Files | Refs | README | LICENSE

commit c6f7605678876dde19878e5bcd85a2001405eb79
parent bf2910189341ad9d4e35b94ea400d530ece5d115
Author: mtmn <miro@haravara.org>
Date:   Sat, 18 Apr 2026 06:31:00 +0200

fix: instrument complete HTTP span

Diffstat:
Mflake.nix | 2+-
Mpackage-lock.json | 43+++++++++++++++++++++++++++++++++++++++++++
Mpackage.json | 2++
Msrc/Main.purs | 30+++++++++++++++---------------
Msrc/Metrics.js | 31+++++++++++++++++++++++--------
Msrc/Metrics.purs | 9++++-----
6 files changed, 88 insertions(+), 29 deletions(-)

diff --git a/flake.nix b/flake.nix @@ -131,7 +131,7 @@ version = "1.0.0"; inherit src; - npmDepsHash = "sha256-MwgWL4nc1d2QoOOdxSeuqfgdNgwBatlDEZaevnOPIGs="; + npmDepsHash = "sha256-yx8mCRd7moAAwVb7uu8U/xhkIsS8JYawonhGmkTUywU="; npmRebuildFlags = ["--ignore-scripts"]; nativeBuildInputs = with pkgs; [ diff --git a/package-lock.json b/package-lock.json @@ -11,6 +11,8 @@ "@aws-sdk/client-s3": "^3.1029.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-trace-otlp-http": "^0.215.0", + "@opentelemetry/instrumentation-http": "^0.215.0", + "@opentelemetry/instrumentation-undici": "^0.25.0", "@opentelemetry/sdk-node": "^0.215.0", "dotenv": "^17.4.1", "duckdb": "^1.4.4", @@ -1805,6 +1807,41 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.215.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.215.0.tgz", + "integrity": "sha512-ip9iNoRRVxDyP8LVfdqqI6OwbOwzxTl4SaP1WDKJq0sDsgpOr7rIOFj7gV8yKl4F5PdDOUYy8VqdgIOWZRlGBw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.0", + "@opentelemetry/instrumentation": "0.215.0", + "@opentelemetry/semantic-conventions": "^1.29.0", + "forwarded-parse": "2.1.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.25.0.tgz", + "integrity": "sha512-yPc3sZ3gwlxArBoW0LXpyE0GA4gORSajFBuME2jAo3YXgwSMI86SmQwFhYlRlYgx9LPWH8LIMDZ4J7cYFfyaBg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.215.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, "node_modules/@opentelemetry/otlp-exporter-base": { "version": "0.215.0", "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.215.0.tgz", @@ -4092,6 +4129,12 @@ "node": ">= 0.12" } }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", + "license": "MIT" + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", diff --git a/package.json b/package.json @@ -23,6 +23,8 @@ "@aws-sdk/client-s3": "^3.1029.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-trace-otlp-http": "^0.215.0", + "@opentelemetry/instrumentation-http": "^0.215.0", + "@opentelemetry/instrumentation-undici": "^0.25.0", "@opentelemetry/sdk-node": "^0.215.0", "dotenv": "^17.4.1", "duckdb": "^1.4.4", diff --git a/src/Main.purs b/src/Main.purs @@ -803,21 +803,21 @@ handleRequest contexts req res = do Nothing -> serveNotFound res Just url -> do let path = URL.pathname url - Metrics.observeHttpRequest method (normalizePath path) Log.info req res - case path of - "/client.js" -> serveClientJs res - "/favicon.png" -> serveAsset "image/png" "assets/favicon.png" res - "/" -> serveIndex "" res - "/metrics" -> serveMetrics res - "/proxy" -> withUser url \ctx -> serveProxy ctx.conn url res - "/stats" -> withUser url \ctx -> serveStats ctx.conn url res - "/cover" -> withUser url \ctx -> serveCover ctx.config ctx.slug url res - "/healthz" -> withUser url \ctx -> serveHealthz ctx.conn res - _ -> case stripPrefix (Pattern "/~") path of - Just slug -> serveIndex slug res - Nothing -> do - Log.warn $ "Path not found: " <> path - serveNotFound res + Metrics.wrapRequest method (normalizePath path) Log.info req res do + case path of + "/client.js" -> serveClientJs res + "/favicon.png" -> serveAsset "image/png" "assets/favicon.png" res + "/" -> serveIndex "" res + "/metrics" -> serveMetrics res + "/proxy" -> withUser url \ctx -> serveProxy ctx.conn url res + "/stats" -> withUser url \ctx -> serveStats ctx.conn url res + "/cover" -> withUser url \ctx -> serveCover ctx.config ctx.slug url res + "/healthz" -> withUser url \ctx -> serveHealthz ctx.conn res + _ -> case stripPrefix (Pattern "/~") path of + Just slug -> serveIndex slug res + Nothing -> do + Log.warn $ "Path not found: " <> path + serveNotFound res where withUser url f = let diff --git a/src/Metrics.js b/src/Metrics.js @@ -7,6 +7,8 @@ import { } from "prom-client"; import { NodeSDK } from "@opentelemetry/sdk-node"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; +import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici"; import { trace, context, @@ -103,6 +105,13 @@ let tracer = null; if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT) { const sdk = new NodeSDK({ traceExporter: new OTLPTraceExporter(), + instrumentations: [ + new HttpInstrumentation({ + // Incoming requests are traced manually via wrapRequest below + ignoreIncomingRequestHook: () => true, + }), + new UndiciInstrumentation(), + ], }); sdk.start(); tracer = trace.getTracer( @@ -150,23 +159,29 @@ export const getMetricsImpl = (onSuccess) => (onError) => () => { export const getContentType = () => registry.contentType; -// Attaches a 'finish' listener to `res` so that, once the response is fully -// written, the request count, latency histogram, and OTEL span are updated. -// logFn: String -> Effect Unit — the structured logger to call on completion. +// Runs `handler` inside the active context of a server span so that any +// outbound HTTP/fetch calls made during handling automatically become child +// spans. Attaches a 'finish' listener to record metrics and end the span. +// logFn: String -> Effect Unit — structured logger called on completion. // req: Node IncomingMessage — used to extract W3C trace-context headers. -export const observeHttpRequest = - (method) => (path) => (logFn) => (req) => (res) => () => { +export const wrapRequest = + (method) => (path) => (logFn) => (req) => (res) => (handler) => () => { const startMs = Date.now(); const span = startHttpSpan(method, path, req.headers || {}); res.once("finish", () => { const durationMs = Date.now() - startMs; - const durationSecs = durationMs / 1000; const status = res.statusCode || 0; - httpRequestsTotal.inc({ method, path, status: String(status) }); - httpRequestDurationSeconds.observe({ method, path }, durationSecs); + if (httpRequestsTotal) httpRequestsTotal.inc({ method, path, status: String(status) }); + if (httpRequestDurationSeconds) httpRequestDurationSeconds.observe({ method, path }, durationMs / 1000); endHttpSpan(span, status); logFn(`${method} ${path} ${status} ${durationMs}ms`)(); }); + if (span) { + const ctx = trace.setSpan(context.active(), span); + context.with(ctx, () => handler()); + } else { + handler(); + } }; export const incSyncRuns = (user) => (source) => (result) => () => diff --git a/src/Metrics.purs b/src/Metrics.purs @@ -14,12 +14,11 @@ foreign import getMetricsImpl :: (String -> Effect Unit) -> (String -> Effect Un -- | The MIME type to use for the /metrics response body. foreign import getContentType :: Effect String --- | Attaches a 'finish' listener to `res` so that, once the response is --- | fully written, the request count, latency histogram, and OTEL span are --- | updated, and logFn is called with "METHOD path status Nms". +-- | Runs the given `Effect Unit` handler inside the active context of a server +-- | span, so outbound HTTP/fetch calls become child spans automatically. +-- | Attaches a 'finish' listener to record metrics and end the span. -- | W3C trace-context headers are extracted from `req` for span parenting. --- | Call once at the top of the request handler before routing. -foreign import observeHttpRequest :: String -> String -> (String -> Effect Unit) -> IncomingMessage IMServer -> ServerResponse -> Effect Unit +foreign import wrapRequest :: String -> String -> (String -> Effect Unit) -> IncomingMessage IMServer -> ServerResponse -> Effect Unit -> Effect Unit -- Sync foreign import incSyncRuns :: String -> String -> String -> Effect Unit