# Express (Node)

> prom-client for metrics, pino for JSON logs, both observed by the agent.

Canonical: https://docs.getlumin.dev/stacks/express/

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

```yaml
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

```sh
pnpm add prom-client
```

```ts
import 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

[`pino`](https://github.com/pinojs/pino) writes one JSON object per log line, which the agent ingests as structured:

```sh
pnpm add pino
```

```ts
import 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

A tiny middleware reads `X-Lumin-Session` and binds it to the request's logger via [`pino`'s child loggers](https://getpino.io/#/docs/child-loggers):

```ts
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](https://docs.getlumin.dev/stacks/browser/#linking-browser-events-to-server-logs) for the whole path, including the browser half.
