Observability & testing: Micrometer, OTel, Testcontainers¶
At a glance
Priority: P1 · Complexity: ⅖ · Est. time: 2 h · Phase: 4 · Prereqs: Spring AI RAG, Tools & MCP, LLM observability, Evals & error analysis You're done when: a request through your Spring AI service produces one distributed trace (HTTP → advisors → model → tools → vector store) exported over OTLP, token/latency metrics appear in a dashboard, and your test suite covers the RAG path with Testcontainers plus LLM-judged evals that gate CI.
Why it matters¶
LLM features fail differently from CRUD: they degrade quietly (quality drift), cost money per call, and are non-deterministic. You need traces that show every model/tool/retrieval step, metrics for tokens and latency, and evals as regression tests. The Java advantage: Spring AI emits Micrometer observations natively, Boot 4 ships an OpenTelemetry starter, and Testcontainers gives real pgvector in tests. The interview-level point: "How do you know your AI feature got worse after a prompt change?" — answer with golden sets and CI evals, not vibes.
Core concepts¶
What Spring AI instruments¶
Spring AI creates Micrometer Observations around chat model calls, embedding calls, vector-store operations, and tool calls (tool observations live under spring.ai.tool). Each observation yields both a span (with the OTel Micrometer bridge) and metrics (timers/counters), following the GenAI-style conventions (gen_ai.* attributes such as system, model, operation, and token usage). As of mid-2026 the OTel GenAI semantic conventions are still in Development status — expect attribute renames; keep dashboards decoupled via a thin naming layer.
flowchart LR
REQ[HTTP request span] --> CC[ChatClient / advisors]
CC --> VS[vector store search span]
CC --> LLM[chat model call span<br/>tokens, model, latency]
LLM --> TOOL[tool call span]
TOOL --> DB[JDBC span]
REQ -.OTLP.-> COL[(OTel Collector)] --> BE[(Tempo/Jaeger · Prometheus · Langfuse/Phoenix)]
Wiring (Boot 4)¶
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>
management:
tracing:
sampling:
probability: 1.0 # dev; use head/tail sampling in prod
otlp:
tracing:
endpoint: http://otel-collector:4318/v1/traces # verify property names against your Boot 4.x reference
spring:
ai:
chat:
observations:
log-prompt: false # PII! enable only in dev / with redaction
log-completion: false
Senior nuances:
- Prompts/completions in telemetry are PII and IP. Spring AI's prompt/completion logging is opt-in for a reason. If you must capture them (debugging, evals), send to a restricted store (Langfuse/Phoenix with access control), redact first, and set retention.
- Cardinality: never use conversation ids, user ids or prompt text as metric tags; use model, operation, advisor, outcome. Put ids on spans/logs.
- Correlate across runtimes: propagate W3C
traceparentfrom the Python orchestrator to the Java MCP server (HTTP headers) so one trace spans LangGraph → MCP → JDBC. Most Python and Java OTel SDKs do this automatically on HTTP. - Cost as a metric: token usage counters × price table = spend per feature/tenant. Alert on tokens per request p95 and on retry/validation-failure rate.
- Tool observations: tool arguments/results are not exported by default (sensitivity); enable selectively.
Backends¶
| Need | Options |
|---|---|
| Traces + metrics (platform) | OTel Collector → Tempo/Jaeger + Prometheus/Grafana, or a vendor APM |
| LLM-specific UI (prompt/response inspection, datasets, evals) | Langfuse (OSS, ClickHouse-owned since Jan 2026), Arize Phoenix — both ingest OTLP |
| Local dev | Grafana LGTM container (grafana/otel-lgtm) or Boot's Docker Compose/Testcontainers observability support |
Testing pyramid for a Spring AI service¶
| Layer | Tooling | What it proves | Determinism |
|---|---|---|---|
| Unit | JUnit 5 + Mockito | Tools, advisors, mapping logic | Full |
| Slice/integration with real infra | Testcontainers (Postgres+pgvector) + @ServiceConnection |
Ingestion, filters, HNSW query, migrations | Full (fake embeddings) |
| Contract | Recorded model responses / MockRestServiceServer / WireMock |
Request shape, tool-loop wiring, error handling | Full |
| Model-in-the-loop evals | RelevancyEvaluator, FactCheckingEvaluator, custom judges, golden sets |
Quality regressions | Statistical |
| Online | Traces, feedback, sampling review | Real-world drift | — |
Testcontainers for RAG:
@SpringBootTest
@Testcontainers
class RagIntegrationTest {
@Container @ServiceConnection
static PostgreSQLContainer pg = new PostgreSQLContainer(
DockerImageName.parse("pgvector/pgvector:pg17").asCompatibleSubstituteFor("postgres"));
// Testcontainers 2.x moved container classes into modules/packages: verify the import
// (org.testcontainers.postgresql.PostgreSQLContainer) against your Boot 4 BOM.
@Autowired VectorStore vectorStore;
@Test
void tenantFilterIsolatesResults() {
vectorStore.add(List.of(
new Document("Detention free time is 5 days", Map.of("tenant", "acme")),
new Document("Detention free time is 2 days", Map.of("tenant", "globex"))));
var hits = vectorStore.similaritySearch(SearchRequest.builder()
.query("free time").topK(5)
.filterExpression("tenant == 'acme'").build());
assertThat(hits).isNotEmpty().allMatch(d -> "acme".equals(d.getMetadata().get("tenant")));
}
}
Use a deterministic fake EmbeddingModel (hash-based vectors) or a small local model for integration tests so CI doesn't call paid APIs; keep one nightly job with the real embedding model.
Testing tool/agent wiring without a live LLM: provide a stub ChatModel bean (test config) that returns scripted tool calls then a final answer; assert the tool was invoked with the right arguments and that ToolContext carried the tenant. This tests your code (loop wiring, limits, error paths), which is deterministic.
LLM-as-judge in JUnit:
@Test
void answerIsRelevantAndGrounded() {
String question = "How many detention days does ACME get at Rotterdam?";
ChatClientResponse res = chat.prompt().advisors(rag).user(question).call().chatClientResponse();
String answer = res.chatResponse().getResult().getOutput().getText();
List<Document> docs = (List<Document>) res.context().get(RetrievalAugmentationAdvisor.DOCUMENT_CONTEXT);
var evaluator = new RelevancyEvaluator(judgeClientBuilder);
EvaluationResponse r = evaluator.evaluate(new EvaluationRequest(question, docs, answer));
assertThat(r.isPass()).isTrue();
}
Eval hygiene: judge model ≠ generator model; pin model versions and temperature 0; run N cases and assert on pass rate thresholds (e.g., ≥ 90% with confidence interval), not single outputs; store failing cases as new golden data; tag eval tests (@Tag("eval")) and run them on prompt/model/retrieval changes plus nightly, not on every commit if cost/time matters. Full methodology: evals & error analysis.
Testing the MCP server¶
- Unit-test
@McpToolmethods as plain methods. - Integration:
@SpringBootTest(webEnvironment = RANDOM_PORT)and an MCP client (Java SDK client or the MCP Inspector CLI) hitting/mcp; asserttools/listschema snapshots (contract tests catch accidental breaking changes to tool names/params) and auth failures (401). - Cross-language: a Python pytest that connects via
langchain-mcp-adaptersto the containerised Java server — the same contract test the orchestrator team relies on.
Resources¶
| Resource | Type | Why this one | Level | Cost |
|---|---|---|---|---|
| Spring AI: Observability | docs | Metrics/span names, prompt logging flags, tool observations | intermediate | free |
| Spring AI: Testing / evaluation | docs | Evaluator interfaces and RAG evaluation examples | intermediate | free |
| Spring Boot: Testcontainers | docs | @ServiceConnection, dynamic properties |
intermediate | free |
| Testcontainers for Java | docs | Container modules incl. Postgres | intermediate | free |
| Micrometer reference | docs | Observation API, naming, cardinality guidance | intermediate | free |
| Langfuse docs | docs | OTLP ingestion, datasets and evals UI | intermediate | free |
| Arize Phoenix | docs | OSS trace UI and evals; OTel-native | intermediate | free |
| Hamel Husain: evals FAQ | article | Practical eval philosophy: error analysis before metrics | advanced | free |
| OTel GenAI semantic conventions repo |
docs | Where the attribute names are defined and changing | advanced | free |
Hands-on lab¶
Goal (90 min): make the RAG service from Spring AI RAG observable and testable.
- Add Actuator + OpenTelemetry starter; run
grafana/otel-lgtm(or Langfuse/Phoenix) locally; export OTLP. - Send five
/askrequests; find a trace showing HTTP → advisor chain → vector search → chat call. Screenshot the span tree and thegen_aitoken metrics. - Propagate
traceparentfrom a Python client (LangGraph agent calling your MCP server) and confirm a single cross-language trace. - Write the Testcontainers pgvector test above (tenant isolation) with a fake
EmbeddingModel. - Write a scripted-
ChatModeltest proving the tool loop callsget_shipment_statuswith the right args and respectsmaxTotalToolCalls. - Add a
@Tag("eval")test running 20 golden Q&A pairs throughRelevancyEvaluator; fail the build below 90% pass rate. Break the retriever (topK=1) to see it fail. - Add a Grafana panel: tokens per request p95 and validation-retry rate.
Expected output: trace screenshot, dashboard panel, green integration tests, a demonstrably red eval when quality regresses.
Questions¶
L1 — Recall¶
Q1. What does Spring AI instrument via Micrometer and how does that reach OpenTelemetry?
Answer
Chat model calls, embeddings, vector-store operations and tool calls are wrapped in Micrometer observations. With the OTel/Micrometer tracing bridge (Boot 4's OpenTelemetry starter), each observation becomes a span and metrics (including GenAI token usage) and is exported over OTLP.
Q2. Why are prompt/completion logging flags off by default?
Answer
Prompts and completions can contain PII, secrets and proprietary data; exporting them to general telemetry backends widens the blast radius and may violate retention/residency policy. Enable only for dev or with redaction, restricted access and short retention.
Q3. What does @ServiceConnection do in a Testcontainers test?
Answer
It lets Spring Boot derive connection properties (JDBC URL, credentials) from the started container automatically, replacing manual @DynamicPropertySource wiring.
L2 — Apply¶
Q4. Design metrics and alerts for an LLM endpoint. Which tags are safe?
Answer
Metrics: request rate/latency (p50/p95/p99), model-call latency, tokens in/out per request (histogram) and total, cost estimate, tool-call count per request, validation-retry rate, refusal/"no context" rate, error rate by type (429, timeout, schema). Alerts: p95 tokens/request +30% week-over-week, retry rate > x%, 429 rate, cost/day budget burn. Safe tags: model, operation, feature, outcome, advisor name — low cardinality. Never user/conversation ids or prompt text (put those on traces/logs).
Q5. Test that your @Tool receives the tenant from ToolContext, without a real LLM.
Answer
Provide a test ChatModel that returns an assistant message containing a tool call for getStatus(shipmentId) on the first invocation and a plain answer after the tool result. Call chatClient.prompt().tools(tools).toolContext(Map.of("tenantId","acme")).user("...").call().content(), and verify with a spy/mock service that it was invoked with tenant="acme". This is deterministic and exercises the real ToolCallingAdvisor wiring.
Q6. Your eval suite is flaky (pass rate swings between 82% and 94%). What do you do?
Answer
Reduce variance: temperature 0, pinned model versions, more cases (a 20-case suite has a huge confidence interval), multiple judge samples or a stricter rubric with explicit criteria, and use binary rubrics instead of 1–10 scores. Compare distributions with confidence intervals rather than single runs; investigate flaky items by reading traces (error analysis) — ambiguous rubric or unstable retrieval is often the cause. Gate on a threshold with a tolerance band and re-run-on-failure policy.
L3 — Design & trade-offs¶
Q7. Langfuse/Phoenix vs plain OTel backend (Tempo/Grafana) for AI traces — decide.
Answer
Plain OTel backend: one observability stack, shared with the rest of the platform, best for SRE-owned production monitoring. LLM-specific tools: prompt/response viewers, sessions, datasets, annotation queues and eval workflows that engineers need for quality work. Practical answer: emit OTLP once and fan out — platform backend for ops, an LLM tool (restricted access) for quality analysis — and be deliberate about what content goes to which.
Q8. Real LLM in CI vs mocked models — what runs where?
Answer
Per-commit: unit + Testcontainers integration + scripted-model tests (fast, free, deterministic). Per-PR touching prompts/retrieval/model config: a targeted eval subset with a real model (cost-capped). Nightly: full golden set, adversarial/red-team cases, cost/latency tracking. Pre-release: shadow traffic comparison. Budget cost explicitly; cache model responses for unchanged inputs where legitimate.
Q9. How much of the prompt/response content should you retain for debugging, and where?
Answer
Minimum viable: metadata always (model, tokens, latency, ids, outcome); content sampled (e.g., 1–5%) plus 100% of errors/low-feedback cases, redacted, stored in an access-controlled LLM-observability store with short retention (e.g., 14–30 days) and deletion on data-subject request. Regulated data may forbid content retention entirely — then rely on synthetic reproductions and hashed references. Decide with security/legal, document in an ADR.
L4 — Staff-level ambiguity¶
Q10. Two weeks after launch, users say answers 'got worse', but dashboards are green. How do you investigate and prevent recurrence?
Answer
Green infra metrics don't measure quality. Investigate: sample recent traces and review failing/low-rated ones (error analysis); diff what changed (model version auto-upgrade, prompt, corpus ingestion, embedding model, retrieval params); replay recent real queries against old vs new config; check retrieval metrics separately (empty-context rate, top-1 score distribution shifts). Prevent: golden set + LLM-judge in CI on every prompt/config change, online quality signals (thumbs, escalation rate, abstention rate) on dashboards with alerts, pinned model versions and canary rollouts of changes, and a periodic error-analysis ritual owned by the feature team.
Q11. Build a shared AI-observability standard for 20 services in Java and Python. What is in it?
Answer
A one-page standard plus libraries: required span structure and attribute names (aligned to OTel GenAI conventions with a mapping layer since they're unstable), mandatory traceparent propagation, metric names/tags and cardinality rules, content-capture policy by data classification, cost attribution tags (feature/tenant), SLOs (latency, error, quality proxies), and shared dashboards. Ship it as a Spring Boot starter and a Python package so adoption is one dependency. Enforce through service scorecards, not gates, at first. Include an eval-in-CI requirement for any prompt/model change.
Real-world use cases¶
- Cost governance: per-feature token spend dashboards that surfaced a runaway retry loop before the invoice.
- RAG regressions: a re-ingestion changed chunking; retrieval-hit-rate panel and CI eval caught it in staging.
- Cross-language debugging: a single trace from a Python agent through the Java MCP tool to a slow SQL query.
- Compliance: provable log of which documents fed which answer, with redacted prompt storage.
Pitfalls & anti-patterns¶
- Exporting raw prompts to a shared APM by default.
- High-cardinality metric tags (conversation id, prompt hash).
- Tests that call paid LLM APIs on every commit, or that assert exact strings from a non-deterministic model.
- Judge and generator being the same model, unpinned versions.
- Only measuring latency/errors — no quality signal.
- Using
initialize-schemain test and prod differently, hiding migration bugs (use the same Flyway migrations in Testcontainers).
Checklist¶
- I can list what Spring AI instruments and where prompt logging is controlled
- I got a single OTLP trace across HTTP, advisors, model and tool calls
- I have Testcontainers pgvector tests and a scripted-
ChatModeltool-loop test - I have an eval test that fails when retrieval quality drops
- I answered all L3 questions out loud in < 3 min each