Observability¶
Mocapi ships an opinionated observability stack built on the per-handler customizer SPI. Four pieces, all opt-in by dropping in the corresponding feature module:
| Concern | Module | What you get |
|---|---|---|
| Correlation logging (SLF4J MDC) | mocapi-logging |
mcp.protocol.version / mcp.client.name / mcp.handler.kind / mcp.handler.name / mcp.request.id MDC keys during every handler invocation |
| Metrics + tracing (Micrometer) | mocapi-o11y |
One Observation per invocation → meters and spans, via whichever Micrometer handlers are on classpath |
| Structured audit log | mocapi-audit |
One SLF4J event per invocation on the mocapi.audit logger with caller / handler / outcome / duration fields |
| Handler inventory endpoint | mocapi-actuator |
/actuator/mcp — server info, handler counts, per-handler metadata + schema digests |
All four attach through the same mechanism — one
*HandlerCustomizer bean per handler kind (see
customizers.md) — which means consistent wiring
shape, zero reflection on the hot path (kind + name are closed over
at startup), and no blind bean-list autowiring to surprise you.
Metrics + tracing (mocapi-o11y)¶
One McpObservationInterceptor wraps every handler invocation in a
Micrometer Observation. The same code emits both metrics and
distributed-tracing spans: whichever ObservationHandlers the user
has on classpath participate automatically —
MeterObservationHandler converts events to Timer / Counter,
TracingObservationHandler converts events to spans. No separate
metrics or tracing interceptors.
Observation shape¶
One observation name per handler kind (so dashboards get distinct
Timers rather than one meter partitioned by tag):
mcp.toolmcp.promptmcp.resourcemcp.resource_template
Low-cardinality tags:
mcp.handler.kind—tool/prompt/resource/resource_templatemcp.handler.name— tool / prompt name, or resource URI / URI template
The standard Micrometer outcome tag (SUCCESS / ERROR) is added
by DefaultMeterObservationHandler on exception automatically.
Enabling metrics¶
Add mocapi-o11y to the classpath. The autoconfig activates when an
ObservationRegistry bean is present — Spring Boot auto-creates one
whenever spring-boot-starter-actuator or any Micrometer Observation
autoconfig is on the classpath. Typical recipe:
<dependency>
<groupId>com.callibrity.mocapi</groupId>
<artifactId>mocapi-o11y</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
That's enough to produce metrics at /actuator/metrics. No concrete
meter registry needed — Spring Boot's SimpleMeterRegistry is the
default and holds everything you need for
/actuator/metrics/mcp.tool to return timers.
Expose the endpoints in application.properties:
management.endpoints.web.exposure.include=health,info,metrics,mcp
Query the data:
# Aggregate across all tools
curl localhost:8080/actuator/metrics/mcp.tool
# One specific tool
curl 'localhost:8080/actuator/metrics/mcp.tool?tag=mcp.handler.name:get_weather'
# Currently-in-flight invocations
curl localhost:8080/actuator/metrics/mcp.tool.active
mcp.tool.active is a LongTaskTimer that tracks in-flight calls —
the right meter for alerting on stuck invocations.
Enabling tracing¶
Add the Micrometer Tracing bridge + Spring Boot's OpenTelemetry autoconfig + the OTel SDK:
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-micrometer-tracing-opentelemetry</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>
Spring Boot 4 split these across three artifacts; the SDK-providing
spring-boot-starter-opentelemetry is the one people miss most
often. Without it, NoopTracerAutoConfiguration wins and trace IDs
come out empty in logs.
Trace IDs flow into Spring Boot's default log pattern automatically
once a real Tracer bean is present — no logging.pattern.*
override needed. Each log line picks up
[<app>,<traceId>,<spanId>].
Exporting spans¶
To ship spans somewhere (Jaeger / Tempo / Honeycomb / Grafana Cloud), add an OTLP exporter and point it at your collector:
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
management.otlp.tracing.endpoint=http://localhost:4318/v1/traces
management.tracing.sampling.probability=1.0
Disable OTLP metrics export unless your collector handles both:
# Jaeger all-in-one accepts traces at /v1/traces but 404s on
# /v1/metrics. Disable metrics OTLP export to avoid the 60s-interval
# warning log.
management.otlp.metrics.export.enabled=false
For dev-only span inspection, one-line Jaeger all-in-one:
docker run -d --rm --name jaeger \
-p 16686:16686 \
-p 4318:4318 \
jaegertracing/all-in-one:latest
Then open http://localhost:16686, select your service, and click
any trace for the http post /mcp → mcp.tool waterfall.
A production-sampling note¶
management.tracing.sampling.probability=1.0 (sample everything) is
fine for dev. In production, typically 0.05-0.10 — the full
observation pipeline runs regardless of sampling, but the
TracingObservationHandler short-circuits the span-exporting work
for unsampled traces. Cuts OTel CPU by ~10-20× with minimal loss of
statistical fidelity.
Interaction with @Observed¶
Users who prefer @Observed on individual tool methods (Spring
Boot's built-in AOP advice) can skip mocapi-o11y and wire their
own observations; the two paths don't fight but do nest if both are
enabled on the same method. Pick one.
Context propagation across the HTTP virtual-thread boundary¶
StreamableHttpController.handleCall spawns a fresh virtual thread
per MCP call. By default, Thread.ofVirtual().start(...) does not
inherit ThreadLocal-backed context (including Spring Security's
SecurityContextHolder, Micrometer Observation scope, and SLF4J
MDC).
Mocapi wires io.micrometer:context-propagation into the spawn so
every ThreadLocalAccessor registered via SPI gets captured on the
request thread and restored on the handler VT. One change at one
call site; every library that ships an accessor participates
automatically. This is what makes tracing trees in Jaeger show
http post /mcp as the parent of the mcp.tool span instead of
two disconnected traces.
The io.micrometer:context-propagation jar is a direct dependency
of mocapi-streamable-http-transport; users don't configure
anything.
Per-run performance methodology¶
See perf/benchmarking.md for the standing
soak-test + JFR-profiling runbook, and
perf/history.md for run-over-run numbers. Run
periodically to detect regressions or validate a performance change.
The four pieces in depth¶
logging.md— MDC correlation keys, Logback pattern snippet, virtual-threads caveat.audit.md— structured audit logger, pluggable caller identity, opt-inarguments_hash.actuator.md—/actuator/mcpendpoint shape, exposure config, security.customizers.md— the underlying extension mechanism every observability module plugs into.
Non-goals¶
- Per-invocation mutable metadata on
MethodInvocation. Methodical intentionally doesn't carry one; interceptors that need to share per-call state useScopedValue. - A unified observer SPI. The whole point of the customizer-driven interceptor model is that everything is the observer SPI. No parallel API.
- Rate limiting in core. A token-bucket
MethodInterceptorwould be trivial on top of the customizer SPI; mocapi doesn't ship one. Users or third-party starters do. - Baking scope / role / tenant models into mocapi. Entitlement plugins (see guards.md) own those semantics.