Skip to content

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.

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.

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: 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.

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.

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.

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

@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 for the whole path, including the browser half.

  • 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.