Spring Boot (JVM)
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
Section titled “Labels on your container”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
Section titled “Enable Prometheus exposition”build.gradle.kts:
dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("io.micrometer:micrometer-registry-prometheus")}application.yaml:
management: endpoints: web: exposure: include: ["health", "prometheus"] endpoint: prometheus: access: read_only metrics: distribution: percentiles-histogram: http.server.requests: trueThe 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
Section titled “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
Section titled “Structured logs”Use the Logstash JSON encoder so logback writes one JSON object per log line. build.gradle.kts:
dependencies { implementation("net.logstash.logback:logstash-logback-encoder:7.4")}logback-spring.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
Section titled “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:
@Componentpublic 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 for the whole path, including the browser half.
Things to watch
Section titled “Things to watch”- The
urilabel 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/prometheusscrape 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.