# Spring Boot (JVM)

> Micrometer + Actuator for metrics, JSON logback for logs, both observed by the agent.

Canonical: https://docs.getlumin.dev/stacks/spring-boot/

Spring Boot already ships with Micrometer wired into Actuator. With the Prometheus registry on the classpath, you get a `/actuator/prometheus` endpoint exposing exactly the metric names Lumin expects when `lumin.metrics.framework=spring` is set.

## Labels on your container

```yaml
services:
  api:
    image: my-spring-api:latest
    labels:
      lumin.logs: "true"
      lumin.service: "api"
      lumin.metrics: "true"
      lumin.metrics.port: "8080"
      lumin.metrics.path: "/actuator/prometheus"
      lumin.metrics.framework: "spring"
```

Note `lumin.metrics.path` — Spring exposes Prometheus on `/actuator/prometheus`, not `/metrics`.

## Enable Prometheus exposition

`build.gradle.kts`:

```kotlin
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("io.micrometer:micrometer-registry-prometheus")
}
```

`application.yaml`:

```yaml
management:
  endpoints:
    web:
      exposure:
        include: ["health", "prometheus"]
  endpoint:
    prometheus:
      access: read_only
  metrics:
    distribution:
      percentiles-histogram:
        http.server.requests: true
```

**The `percentiles-histogram` line is not optional if you want latency charts.** Micrometer records HTTP traffic as a Timer, and a Timer emits only `_count` and `_sum` by default. The `_bucket` rows that Lumin's p50 / p95 / p99 panels read appear only when you turn this on. Throughput and error rate work either way.

Restart the service. `curl http://localhost:8080/actuator/prometheus` should now return a few hundred lines including `http_server_requests_seconds_count{…}` and `http_server_requests_seconds_bucket{…}`. The agent maps these to Lumin's `http.requests` and `http.latency_ms` automatically when the framework label is `spring`.

## How the mapping works

Micrometer exposes no `http_server_requests_total` counter. The request count lives in the Timer's `_count` sibling, so that one series feeds two canonical metrics:

| Actuator series | Lumin metric |
|---|---|
| `http_server_requests_seconds_count` | `http.requests` `{method, route, status_class}`, plus `http.errors` `{method, route, status}` for 5xx |
| `http_server_requests_seconds_bucket` | `http.latency_ms` `{method, route}`, converted from seconds |
| `http_server_requests_seconds_sum` | `http.latency_ms_sum`, converted from seconds |
| `http_server_requests_seconds_max` | dropped — it is a seconds gauge and would chart in the wrong unit |
| `jvm_*`, `hikaricp_*`, everything else | ingested verbatim under their own names |

The `uri` label becomes `route`. The `outcome` and `exception` labels are dropped, because `exception` is a class name and would multiply the cardinality of every series it touches.

JVM memory, garbage collection, and HikariCP pool metrics are stored and queryable, and you can chart them from the Metrics page by name. They are not part of the canonical `http.*` schema, so they do not appear in the canned KPI panels.

## Structured logs

Use the Logstash JSON encoder so logback writes one JSON object per log line. `build.gradle.kts`:

```kotlin
dependencies {
    implementation("net.logstash.logback:logstash-logback-encoder:7.4")
}
```

`logback-spring.xml`:

```xml
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
      <fieldNames>
        <timestamp>time</timestamp>
        <message>message</message>
        <level>level</level>
      </fieldNames>
    </encoder>
  </appender>
  <root level="INFO">
    <appender-ref ref="STDOUT"/>
  </root>
</configuration>
```

Lumin reads `level` out of the object to set the row's level, and stores the whole object as `attributes`. The row's timestamp comes from Docker's log timestamp rather than the encoder's `time` field, and `message` keeps the raw JSON line. Anything you add with `MDC.put(...)` travels in `attributes`; nothing is promoted to a dedicated column.

## Propagating browser session IDs

A simple `OncePerRequestFilter` reads `X-Lumin-Session` from the request and puts it on MDC so every log line in the request includes it:

```java
@Component
public class LuminSessionFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String sid = req.getHeader("X-Lumin-Session");
        if (sid != null) MDC.put("session_id", sid);
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.remove("session_id");
        }
    }
}
```

Every log line inside the request now carries `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.

## Things to watch

- **The `uri` label in Spring's default metrics is templated** (`/projects/{id}`), which is exactly what you want. Don't enable any Actuator option that records expanded paths — your cardinality will blow up.
- **Actuator endpoints are noisy by default.** The `/actuator/prometheus` scrape itself can show up in metrics if your filters include actuator URIs. Spring's default tagging excludes them; if you've overridden tagging, exclude `/actuator/*` explicitly.
