commit c6f7605678876dde19878e5bcd85a2001405eb79
parent bf2910189341ad9d4e35b94ea400d530ece5d115
Author: mtmn <miro@haravara.org>
Date: Sat, 18 Apr 2026 06:31:00 +0200
fix: instrument complete HTTP span
Diffstat:
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