--- name: otel-setup description: "Wire OpenTelemetry end to end into an existing Spring Boot 4 Maven project — OTLP export for traces, metrics, and logs, the Logback appender Boot doesn't ship, a local Grafana LGTM backend, and a runbook proving all three signals land. Use when asked to add observability or OpenTelemetry, or when logs never reach the backend." --- # OpenTelemetry Setup Skill Takes a Spring Boot 4 app from no telemetry to traces, metrics, and logs landing in a backend you can open in a browser — and proves it before saying it's done. `SKILL_DIR` = the directory containing this SKILL.md. **Load `SKILL_DIR/references/otel-reference.md` before writing anything** — property map, the appender version matrix, production knobs, and the symptom → cause → fix table. The starter exports what Micrometer instruments; it does **not** instrument anything itself, and it does **not** ship a Logback appender. Step 3 is the one everyone skips, and it is why an app can have a log-export endpoint configured and export zero logs. --- ## Step 0 — Read the project ```bash grep -m1 -A1 'spring-boot-starter-parent' pom.xml grep -rn 'opentelemetry\|otlp\|management:' src/main/resources/application.y*ml | head -20 ls src/main/resources/logback-spring.xml compose.yaml 2>/dev/null grep -rn 'spring-boot-docker-compose\|spring-boot-starter-actuator\|logback-appender' pom.xml ``` Establish the Boot version, what telemetry config already exists, whether a Logback config file is already in play, and which backend the project has (compose, Testcontainers, or an external collector). **Boot 4.0.1 is the floor.** On 4.0.0 the OTLP logging auto-configuration lived in the actuator module; if the project is on 4.0.0, say so and stop at Step 2 — log export there needs `spring-boot-starter-actuator` and the fix is a patch upgrade, not a workaround. --- ## Step 1 — Add the starter ```xml org.springframework.boot spring-boot-starter-opentelemetry ``` One dependency, no version — the BOM owns it. Do not add the community `io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter`; it is a different artifact that pulls alpha dependencies to do the same job. Actuator is a separate decision: the starter gives telemetry, Actuator gives `/actuator/health` and the readiness/liveness probes. Add it too when the app runs in Kubernetes — the reference explains the split. --- ## Step 2 — Configure the three export paths ```yaml spring: application: name: # becomes service.name — without it, Tempo shows unknown_service:java management: otlp: metrics: export: url: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/metrics opentelemetry: tracing: export: otlp: endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/traces logging: export: otlp: endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/logs tracing: sampling: probability: 1.0 # explicit — the default is 0.1 and silently drops 90% of traces ``` Metrics go through Micrometer's registry, traces and logs through the OTel SDK; that is why there are two prefixes for three signals. The pre-4.1 flat keys (`management.otlp.tracing.*`, `management.otlp.logging.*`) are deprecated — never generate them. Skip these endpoint properties entirely when the project uses Docker Compose or Testcontainers service connections (Step 4) — the connection details win, and a hardcoded endpoint alongside them is dead config that misleads the next reader. --- ## Step 3 — Wire log export (the part Boot does not ship) Boot auto-configures an OTLP log **exporter**, but nothing feeds application logs into it. Three pieces, all required — with only two of them, the endpoint sits there exporting nothing. **3a. The appender dependency**, version-matched to the OTel API that Boot pins — on Boot 4.1.1 (`opentelemetry.version` 1.62.0) that is `2.28.1-alpha`. Recompute with the matrix commands in `references/otel-reference.md` for any other Boot version, and pin the match rather than the newest. ```xml io.opentelemetry.instrumentation opentelemetry-logback-appender-1.0 2.28.1-alpha ``` Every release of this artifact carries `-alpha` — there is no stable line, so that suffix is not a reason to avoid it or to reach for something older. **3b. `src/main/resources/logback-spring.xml`:** ```xml ``` Including Boot's `base.xml` is what keeps the console pattern — and its trace/span IDs — intact. Both appenders on the root logger: console for humans, OTEL for the backend. **3c. The install bean** — the appender needs the `OpenTelemetry` instance, and only Spring has it: ```java @Component class InstallOpenTelemetryAppender implements InitializingBean { private final OpenTelemetry openTelemetry; InstallOpenTelemetryAppender(OpenTelemetry openTelemetry) { this.openTelemetry = openTelemetry; } @Override public void afterPropertiesSet() { OpenTelemetryAppender.install(this.openTelemetry); } } ``` `InitializingBean` over `@PostConstruct` here for ordering: the appender must not be installed before the auto-configured `OpenTelemetry` bean exists, or early logs export without trace context. --- ## Step 4 — Give it a backend **Docker Compose** (`spring-boot-docker-compose` on the classpath) — Boot detects the container and configures all three endpoints itself: ```yaml services: otel-lgtm: image: grafana/otel-lgtm:0.34.0 # pin it; never :latest ports: - "3000:3000" # Grafana - "4317:4317" # OTLP gRPC - "4318:4318" # OTLP HTTP ``` **Testcontainers** — `LgtmStackContainer` with `@ServiceConnection` in `TestcontainersConfiguration`, run via `./mvnw spring-boot:test-run`. Details in the `spring-testing` skill. **Anything else** — set `OTEL_EXPORTER_OTLP_ENDPOINT` and let Step 2's config read it. Never gate app startup on the collector: a missing backend produces export warnings, not a failure, and `depends_on` a 1 GB image makes every `docker compose up` slow for nothing. --- ## Step 5 — Verify all three signals Configuration that has never been observed working is not done. Run the app, generate traffic, then check each signal: ```bash ./mvnw spring-boot:run curl localhost:8080/ # a few times, including one that logs ``` | Signal | Where | What proves it | |---|---|---| | Traces | Grafana → Explore → **Tempo** → Search, service = `` | A trace with spans for the endpoint | | Metrics | Grafana → Explore → **Prometheus** → `http_server_requests_seconds_count` | A non-zero count for the route | | Logs | Grafana → Explore → **Loki** → `{service_name=""}` | Your log lines, each carrying a trace ID | | Correlation | Click a log's trace ID | It opens the matching trace in Tempo | Grafana is on `http://localhost:3000`; under `spring-boot:test-run` the container logs the mapped URL at startup instead. The console line proves trace context locally — `[traceId-spanId]` between the thread name and the logger: ``` 2026-08-18T11:30:05.801 INFO 13165 --- [ot] [nio-8080-exec-2] [f64d5e13e35ac042-2d46fc21f9ddfb2d] c.y.HomeController : Greeting user: World ``` Logs in Loki that carry **no** trace ID mean the appender was installed too early — check 3c. --- ## Step 6 — Report Lead with the verdict, then the board: ``` OTel wired · 3/3 signals verified in Grafana ━━━ OTEL-SETUP ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Starter ............... ✅ spring-boot-starter-opentelemetry (BOM-managed) Export config ......... ✅ metrics · traces · logs — sampling 1.0 (explicit) Log appender .......... ✅ 2.28.1-alpha (matched to OTel API 1.62.0) Backend ............... ✅ grafana/otel-lgtm:0.34.0 via compose Traces ................ ✅ visible in Tempo, service=job-board Metrics ............... ✅ http_server_requests_seconds_count = 7 Logs .................. ✅ visible in Loki, trace IDs present Correlation ........... ✅ log → trace jump works ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Next: set sampling.probability and deployment.environment for prod before deploying. ``` Mark a signal ⚠ if it was configured but not observed, and say why — "Docker unavailable, backend not started" beats a green board that nobody checked. Never report a signal green from configuration alone.