Express (Node)
The Node story is prom-client for metrics and pino (or any JSON logger) writing to stdout. The agent observes both — no application-side Lumin SDK on the server.
Labels on your container
Section titled “Labels on your container”services: api: image: my-express-api:latest labels: lumin.logs: "true" lumin.service: "api" lumin.metrics: "true" lumin.metrics.port: "8080" lumin.metrics.framework: "express"Emit Prometheus metrics
Section titled “Emit Prometheus metrics”pnpm add prom-clientimport express from "express";import client from "prom-client";
const requests = new client.Counter({ name: "http_requests_total", help: "Total HTTP requests", labelNames: ["method", "route", "status_class"],});
const latency = new client.Histogram({ name: "http_request_duration_ms", help: "HTTP request latency in ms", labelNames: ["method", "route"], buckets: [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000],});
const errors = new client.Counter({ name: "http_errors_total", help: "HTTP 5xx responses", labelNames: ["method", "route", "status"],});
export function httpMetrics() { return (req, res, next) => { const start = process.hrtime.bigint(); res.on("finish", () => { const route = req.route?.path ?? req.baseUrl + (req.route?.path ?? ""); if (!route) return; // 404, skip const statusClass = `${Math.floor(res.statusCode / 100)}xx`; const ms = Number((process.hrtime.bigint() - start) / 1_000_000n);
requests.inc({ method: req.method, route, status_class: statusClass }); latency.observe({ method: req.method, route }, ms); if (res.statusCode >= 500) { errors.inc({ method: req.method, route, status: String(res.statusCode) }); } }); next(); };}
const app = express();app.use(httpMetrics());app.get("/metrics", async (_req, res) => { res.set("Content-Type", client.register.contentType); res.send(await client.register.metrics());});app.listen(8080);Watch the route extraction — req.route is only populated after the route has matched, so the middleware reads it on the res.finish event, not at the top of the handler chain. If req.route is undefined it means no route matched (a 404); skip recording for those to keep cardinality bounded.
Structured logs
Section titled “Structured logs”pino writes one JSON object per log line, which the agent ingests as structured:
pnpm add pinoimport pino from "pino";
const logger = pino({ level: process.env.LOG_LEVEL ?? "info", formatters: { level: (label) => ({ level: label }), }, timestamp: () => `,"time":"${new Date().toISOString()}"`,});
logger.info({ port: 8080 }, "server starting");Lumin reads level out of the object to set the row’s level, and stores the whole object as attributes. The time and msg fields stay inside attributes — the row’s timestamp comes from Docker’s log timestamp, and message keeps the raw JSON line.
Propagating browser session IDs
Section titled “Propagating browser session IDs”A tiny middleware reads X-Lumin-Session and binds it to the request’s logger via pino’s child loggers:
app.use((req, _res, next) => { const sid = req.header("x-lumin-session"); req.log = sid ? logger.child({ session_id: sid }) : logger; next();});
// In a route handler:app.get("/projects/:id", (req, res) => { req.log.info({ project_id: req.params.id }, "fetched project"); res.json({ ok: true });});Every log line via req.log now includes session_id.
Filter the Logs page by one session with the attr. prefix, because the field lands inside attributes rather than in a column of its own:
attr.session_id:0f8c2a41-...Live tail honours the same term, and clicking session_id in a log row’s detail panel composes it for you. There is no clickable pivot between the Sessions page and the Logs page — see Linking browser events to server logs for the whole path, including the browser half.