1. Instrument Everything
In this step every Java service starts sending traces, metrics and logs to one container, otel-lgtm, without a single change to business code. Then you fix the one place where automatic instrumentation cannot see the request: the outbox hop of lab 7. All commands run from the root of your boxoffice repository.
The backend: otel-lgtm
grafana/otel-lgtm packs a whole observability backend into one image, for development and teaching: an OpenTelemetry Collector receives OTLP (the OpenTelemetry Protocol) and routes each signal to Prometheus (metrics), Tempo (traces) and Loki (logs), with Grafana on top, already wired so that a metric exemplar opens its trace and a trace opens its logs. In production these are separate, replicated systems (or a managed service); the protocol your services speak — OTLP — stays the same.
otel-lgtm:
image: grafana/otel-lgtm:0.37.0
ports:
- "3000:3000" # Grafana (no login)
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
volumes:
- ./infra/grafana/dashboards.yaml:/otel-lgtm/grafana/conf/provisioning/dashboards/boxoffice.yaml:ro
- ./infra/grafana/dashboards:/otel-lgtm/boxoffice-dashboards:ro
- ./infra/grafana/alerting:/otel-lgtm/grafana/conf/provisioning/alerting:ro
The three volumes load your dashboard and alert rules at startup; their files are created in step 2 and step 3.
The agent: one jar, no code
The OpenTelemetry Java agent is attached to the JVM with -javaagent. At class-loading time it rewrites the bytecode of the libraries it knows — Spring MVC and WebFlux, the HTTP clients, JDBC and HikariCP, Kafka, Redis, logback, the JVM itself — so that each request becomes a span, each library reports metrics with standard names (the OpenTelemetry semantic conventions), and each log line carries the trace id of the request that wrote it.
Download it once into infra/otel/ (it is 26 MB: .gitignore keeps it out of Git, and every clone runs the script):
#!/bin/sh
# Downloads the OpenTelemetry Java agent into infra/otel/ (mounted read-only into every Java service).
# Usage, from the repository root: sh scripts/get-otel-agent.sh
set -e
VERSION=2.32.0
mkdir -p infra/otel
curl -fsSL -o infra/otel/opentelemetry-javaagent.jar \
"https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${VERSION}/opentelemetry-javaagent.jar"
ls -l infra/otel/opentelemetry-javaagent.jar
# build output
target/
*.class
# IDEs and OS
.idea/
.vscode/
*.iml
.DS_Store
# local secrets: never commit them
.env
# k6 HTML reports are big; commit the JSON summaries and the tables in results/*.md
results/*.html
# the OpenTelemetry Java agent is downloaded by scripts/get-otel-agent.sh (lab 8)
infra/otel/*.jar
The images do not change: Compose mounts infra/otel/ read-only into every Java service and turns the agent on with environment variables shared through a YAML anchor:
# lab 8: OpenTelemetry settings shared by every Java service (merged into each "environment")
x-otel: &otel
JAVA_TOOL_OPTIONS: -javaagent:/otel/opentelemetry-javaagent.jar
OTEL_JAVAAGENT_ENABLED: ${OTEL_ON:-true} # false = same image, agent off
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-lgtm:4318
OTEL_METRIC_EXPORT_INTERVAL: "10000" # 10 s instead of 60 s
OTEL_RESOURCE_ATTRIBUTES: service.namespace=boxoffice,deployment.environment.name=local
OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_always_on}
OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-1.0}
LOGGING_PATTERN_CORRELATION: "[%X{trace_id:-},%X{span_id:-}] " # trace id in console logs
# ... and in every Java service, for example:
order:
build: ./services/order
volumes:
- ./infra/otel:/otel:ro
environment:
<<: *otel
OTEL_SERVICE_NAME: order
| Variable | Effect |
|---|---|
JAVA_TOOL_OPTIONS | the JVM reads it before main: attaches the agent without changing the image or the ENTRYPOINT |
OTEL_JAVAAGENT_ENABLED | false turns the agent off with the same image — used in the Measure it step |
OTEL_SERVICE_NAME | the service.name attribute: the name you filter by everywhere. The replicas catalog-1..3 all use catalog: one service, three instances |
OTEL_EXPORTER_OTLP_ENDPOINT | where to send; the agent uses OTLP over HTTP (4318) by default |
OTEL_METRIC_EXPORT_INTERVAL | 10 s instead of the default 60 s, so that dashboards react during a lab |
OTEL_RESOURCE_ATTRIBUTES | extra attributes on every signal; service.namespace groups the BoxOffice services |
OTEL_TRACES_SAMPLER | which requests are traced; all of them by default (experiment B of Measure it changes it) |
LOGGING_PATTERN_CORRELATION | Spring Boot's logging.pattern.correlation: prints [trace id,span id] in every console line |
Get the complete file of this lab (it is the lab 7 file plus the anchor, the volumes, otel-lgtm, and a longer health-check start_period for the Java services):
curl -fsSL -o docker-compose.yaml https://insper.github.io/platform/2027.1/hands-on/8/code/docker-compose.yaml
docker-compose.yaml — the complete file at the end of this lab
name: boxoffice
# lab 8: OpenTelemetry settings shared by every Java service (merged into each "environment")
x-otel: &otel
JAVA_TOOL_OPTIONS: -javaagent:/otel/opentelemetry-javaagent.jar
OTEL_JAVAAGENT_ENABLED: ${OTEL_ON:-true} # false = same image, agent off
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-lgtm:4318
OTEL_METRIC_EXPORT_INTERVAL: "10000" # 10 s instead of 60 s
OTEL_RESOURCE_ATTRIBUTES: service.namespace=boxoffice,deployment.environment.name=local
OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_always_on}
OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-1.0}
LOGGING_PATTERN_CORRELATION: "[%X{trace_id:-},%X{span_id:-}] " # trace id in console logs
# settings shared by the three catalog replicas (a YAML anchor)
x-catalog: &catalog
build: ./services/catalog
image: boxoffice-catalog
volumes:
- ./infra/otel:/otel:ro # lab 8: the Java agent
environment:
<<: *otel
OTEL_SERVICE_NAME: catalog # three replicas, ONE service
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
REDIS_HOST: redis
REDIS_PORT: 6379
CACHE_ENABLED: ${CACHE_ENABLED:-true}
CACHE_TTL_EVENT: ${CACHE_TTL_EVENT:-60s}
CACHE_TTL_LIST: ${CACHE_TTL_LIST:-10s}
CACHE_STAMPEDE_PROTECTION: ${CACHE_STAMPEDE_PROTECTION:-false}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8082/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 120s # lab 8: with the agent, JVMs starting together need up to ~90 s
retries: 3
services:
db:
image: postgres:17
command: ["postgres", "-c", "max_connections=${DB_MAX_CONNECTIONS:-100}"]
environment:
POSTGRES_DB: ${DB_NAME:-boxoffice}
POSTGRES_USER: ${DB_USER:-boxoffice}
POSTGRES_PASSWORD: ${DB_PASSWORD:-boxoffice}
REPLICATION_PASSWORD: ${REPLICATION_PASSWORD:-replicator}
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
# runs only when the volume is new: creates the "replicator" user
- ./infra/postgres/10-replication.sh:/docker-entrypoint-initdb.d/10-replication.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
# read-only streaming replica of db (hot standby)
db-replica:
image: postgres:17
user: postgres
entrypoint: ["/usr/local/bin/replica-entrypoint.sh"]
environment:
PGPASSWORD: ${REPLICATION_PASSWORD:-replicator} # used by pg_basebackup
REPLICA_DELAY: ${REPLICA_DELAY:-0} # e.g. 2s: artificial lag
ports:
- "5433:5432"
volumes:
- replica-data:/var/lib/postgresql/data
- ./infra/postgres/replica-entrypoint.sh:/usr/local/bin/replica-entrypoint.sh:ro
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pg_isready -U boxoffice -d boxoffice"]
interval: 5s
timeout: 3s
retries: 20
pgbouncer:
image: edoburu/pgbouncer:v1.26.0-p0
ports:
- "6432:6432"
volumes:
- ./infra/pgbouncer/pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini:ro
- ./infra/pgbouncer/userlist.txt:/etc/pgbouncer/userlist.txt:ro
depends_on:
db:
condition: service_healthy
redis:
image: redis:8-alpine
# a pure cache: bounded memory, evict least-recently-used keys, no persistence
command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru",
"--save", "", "--appendonly", "no"]
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
# three identical replicas; no "ports:" — only the edge is reachable from outside
catalog-1:
<<: *catalog
hostname: catalog-1
catalog-2:
<<: *catalog
hostname: catalog-2
catalog-3:
<<: *catalog
hostname: catalog-3
inventory:
build: ./services/inventory
volumes:
- ./infra/otel:/otel:ro
environment:
<<: *otel
OTEL_SERVICE_NAME: inventory
DB_HOST: ${INVENTORY_DB_HOST:-pgbouncer} # writes go through PgBouncer...
DB_PORT: ${INVENTORY_DB_PORT:-6432}
DB_MIGRATION_HOST: db # ...migrations straight to the primary
DB_REPLICA_HOST: ${DB_REPLICA_HOST:-db-replica}
DB_REPLICA_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
DB_POOL_SIZE: ${INVENTORY_POOL_SIZE:-20}
INVENTORY_LOCKING: ${INVENTORY_LOCKING:-pessimistic}
INVENTORY_HOLD_TTL: ${INVENTORY_HOLD_TTL:-PT10M}
ports:
- "8083:8083" # published for experiments; clients go through the edge
depends_on:
db:
condition: service_healthy
db-replica:
condition: service_healthy
pgbouncer:
condition: service_started
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8083/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 120s
retries: 3
auth:
build: ./services/auth
volumes:
- ./infra/otel:/otel:ro
environment:
<<: *otel
OTEL_SERVICE_NAME: auth
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
JWT_ISSUER: ${JWT_ISSUER:-boxoffice-auth}
JWT_TTL: ${JWT_TTL:-PT1H}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@boxoffice.dev}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-boxoffice-admin}
depends_on:
db:
condition: service_healthy
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 120s
retries: 3
gateway:
build: ./services/gateway
volumes:
- ./infra/otel:/otel:ro
environment:
<<: *otel
OTEL_SERVICE_NAME: gateway
AUTH_URL: http://auth:8081
INVENTORY_URL: http://inventory:8083
AUTH_JWKS_URI: http://auth:8081/auth/.well-known/jwks.json
JWT_ISSUER: ${JWT_ISSUER:-boxoffice-auth}
ports:
- "8080:8080" # published for experiments; clients use the edge (port 80)
depends_on:
- auth
- inventory
- catalog-1
- catalog-2
- catalog-3
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 120s
retries: 3
edge:
image: nginx:1.30-alpine
ports:
- "80:80"
volumes:
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- gateway
# ---- lab 7: Kafka (KRaft), Kafka UI, Mailpit, order and notification ----
kafka:
image: apache/kafka:4.3.1
hostname: kafka
ports:
- "9094:9094"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
healthcheck:
test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 > /dev/null 2>&1"]
interval: 10s
timeout: 10s
start_period: 60s # a busy laptop can take a minute to start the broker
retries: 20
kafka-init:
image: apache/kafka:4.3.1
depends_on:
kafka:
condition: service_healthy
entrypoint: ["/bin/sh", "-c"]
command:
- |
/opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 --create --if-not-exists \
--topic order-events --partitions 6 --replication-factor 1 &&
/opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 --create --if-not-exists \
--topic order-events-dlt --partitions 6 --replication-factor 1
restart: "no"
kafka-ui:
image: kafbat/kafka-ui:v1.5.0
depends_on:
kafka:
condition: service_healthy
ports:
- "8089:8080"
environment:
KAFKA_CLUSTERS_0_NAME: boxoffice
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092
mailpit:
image: axllent/mailpit:v1.31
ports:
- "8025:8025"
- "1025:1025"
order:
build: ./services/order
volumes:
- ./infra/otel:/otel:ro
environment:
<<: *otel
OTEL_SERVICE_NAME: order
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
REDIS_HOST: redis
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
NOTIFICATION_URL: http://notification:8086
NOTIFY_MODE: ${NOTIFY_MODE:-outbox} # outbox (after) | sync (before)
ADMIT_PER_SECOND: ${ADMIT_PER_SECOND:-20} # waiting-room admission rate
ports:
- "8084:8084"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
kafka-init:
condition: service_completed_successfully
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 120s
retries: 3
notification:
build: ./services/notification
volumes:
- ./infra/otel:/otel:ro
environment:
<<: *otel
OTEL_SERVICE_NAME: notification
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
MAIL_HOST: mailpit
NOTIFY_CONCURRENCY: ${NOTIFY_CONCURRENCY:-3} # consumer threads (max useful: 6 partitions)
EMAIL_LATENCY: ${EMAIL_LATENCY:-50ms} # simulated e-mail provider latency
NOTIFY_IDEMPOTENT: ${NOTIFY_IDEMPOTENT:-true} # false = no dedupe (to see duplicates)
ports:
- "8086:8086"
depends_on:
db:
condition: service_healthy
mailpit:
condition: service_started
kafka-init:
condition: service_completed_successfully
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8086/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 120s
retries: 3
# ---- lab 8: observability backend (Grafana, Prometheus, Tempo, Loki, OTel Collector) ----
otel-lgtm:
image: grafana/otel-lgtm:0.37.0
ports:
- "3000:3000" # Grafana (no login)
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
volumes:
- ./infra/grafana/dashboards.yaml:/otel-lgtm/grafana/conf/provisioning/dashboards/boxoffice.yaml:ro
- ./infra/grafana/dashboards:/otel-lgtm/boxoffice-dashboards:ro
- ./infra/grafana/alerting:/otel-lgtm/grafana/conf/provisioning/alerting:ro
volumes:
db-data:
replica-data:
Create the provisioning folders (steps 2 and 3 fill them) and save the dashboard provider, explained in step 2, before the first up: Compose mounts infra/grafana/dashboards.yaml as a file, and when it does not exist Docker creates an empty directory with that name — Grafana then fails to read it and otel-lgtm exits.
apiVersion: 1
providers:
- name: BoxOffice
folder: BoxOffice
type: file
options:
path: /otel-lgtm/boxoffice-dashboards
Then restart everything — the Java services restart to pick up the agent, and --wait returns when every service is healthy:
With the agent, each JVM starts noticeably slower (instrumentation happens at class loading): with all of them starting together, catalog took about 75–90 s in a test run, which is why the Java services' health checks now allow a start_period of 120 s. The first request through the edge also warms up the gateway's balancer and catalog's connections to Redis and the database: on a cold stack it can take longer than the gateway's 5 s timeout and come back 504 — repeat it.
Open Grafana at http://localhost:3000 (no login in this image) → Explore. You should see:
- Prometheus:
sum by (service_name) (http_server_request_duration_seconds_count)returns one line per service —auth,catalog,gateway,inventory,notification,order. Each series has labels such ashttp_route="/catalog/events/{id}",http_request_method="GET"andhttp_response_status_code="200"— the route is the template, never the raw URL, which keeps the number of series bounded. - Tempo: search by service
gateway. A request through the edge shows spans fromgatewayandcatalog, and insidecatalog, when the event is not in the Redis cache, one span per SQL statement. - Loki:
{service_name="order"}. Each line hastrace_idandspan_id, and a button that jumps to the trace. The console has them too:docker compose logs orderprints lines like[14263dc50b861be8bcf0bc1f4dbad3c5,a1c96ae57fb41b45] o.s.web.servlet.DispatcherServlet ….
Metric names follow the semantic conventions
The HTTP server duration histogram is called http.server.request.duration in OpenTelemetry; Prometheus receives it as http_server_request_duration_seconds_bucket, _count and _sum (dots become underscores, the unit becomes a suffix). Resource attributes such as service.name become labels (service_name) and job becomes boxoffice/catalog (namespace/name). Every dashboard and alert of this lab relies on these names.
The hop the agent cannot see: the outbox
Place an order and look for its trace in Tempo. With the lab 7 code you find two unrelated traces: one for POST /orders (order → INSERTs), and another one that starts in the outbox relay's scheduled job and continues through Kafka to notification. The agent propagates context across HTTP calls and Kafka messages automatically, but the outbox is a database row read later by a different thread: the context of the request is not in the row, so it is lost.
sequenceDiagram
participant O as order (HTTP thread)
participant DB as outbox table
participant R as relay (scheduler thread)
participant K as Kafka
participant N as notification
O->>DB: INSERT outbox row + traceparent of the request
R->>DB: SELECT row (with its traceparent)
Note over R: make that context current
R->>K: send: the agent injects the SAME trace id in the headers
K->>N: consume: the agent continues the trace The fix is the same idea W3C Trace Context uses on HTTP: carry the traceparent with the data. Store it in the outbox row, and make it the current context when the relay publishes. Four files of order change:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>boxoffice</groupId>
<artifactId>order</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- lab 8: only the API (no SDK); the Java agent provides the implementation at runtime
and, without the agent, every call is a no-op. Version managed by Spring Boot. -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
</dependency>
</dependencies>
<build>
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
package boxoffice.order.outbox;
import tools.jackson.databind.ObjectMapper;
import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator;
import io.opentelemetry.context.Context;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Component
public class Outbox {
private final JdbcClient jdbc;
private final ObjectMapper mapper;
public Outbox(JdbcClient jdbc, ObjectMapper mapper) {
this.jdbc = jdbc;
this.mapper = mapper;
}
/** MANDATORY: fails fast if someone calls it outside the business transaction. */
@Transactional(propagation = Propagation.MANDATORY)
public void append(UUID eventId, String aggregateType, UUID aggregateId, String eventType, Object payload) {
String json = mapper.writeValueAsString(payload); // Jackson 3: exceptions are unchecked
// capture the trace context of the current request (null without the agent)
Map<String, String> carrier = new HashMap<>();
W3CTraceContextPropagator.getInstance().inject(Context.current(), carrier, (c, k, v) -> c.put(k, v));
jdbc.sql("""
INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload, traceparent)
VALUES (?, ?, ?, ?, ?::jsonb, ?)
""")
.params(eventId, aggregateType, aggregateId, eventType, json, carrier.get("traceparent"))
.update();
}
}
package boxoffice.order.outbox;
import boxoffice.order.OrderProperties;
import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.Scope;
import io.opentelemetry.context.propagation.TextMapGetter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.SendResult;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class OutboxRelay {
private static final Logger log = LoggerFactory.getLogger(OutboxRelay.class);
record Pending(UUID id, UUID aggregateId, String eventType, String payload, String traceparent) {
}
/** Reads the "traceparent" key from a Pending row. */
private static final TextMapGetter<Pending> ROW = new TextMapGetter<>() {
@Override
public Iterable<String> keys(Pending p) {
return List.of("traceparent");
}
@Override
public String get(Pending p, String key) {
return p == null || !"traceparent".equals(key) ? null : p.traceparent();
}
};
private final JdbcClient jdbc;
private final JdbcTemplate jdbcTemplate;
private final KafkaTemplate<String, String> kafka;
private final String topic;
public OutboxRelay(JdbcClient jdbc, JdbcTemplate jdbcTemplate,
KafkaTemplate<String, String> kafka, OrderProperties props) {
this.jdbc = jdbc;
this.jdbcTemplate = jdbcTemplate;
this.kafka = kafka;
this.topic = props.topic();
}
@Scheduled(fixedDelayString = "${boxoffice.order.relay-interval-ms:200}")
@Transactional
public void relay() throws Exception {
// SKIP LOCKED: several order replicas can relay in parallel without taking the same rows
List<Pending> batch = jdbc.sql("""
SELECT id, aggregate_id, event_type, payload::text AS payload, traceparent
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED
""")
.query(Pending.class)
.list();
if (batch.isEmpty()) {
return;
}
List<CompletableFuture<SendResult<String, String>>> acks = new ArrayList<>();
for (Pending p : batch) {
ProducerRecord<String, String> record =
new ProducerRecord<>(topic, p.aggregateId().toString(), p.payload());
record.headers().add("eventId", p.id().toString().getBytes(StandardCharsets.UTF_8));
record.headers().add("eventType", p.eventType().getBytes(StandardCharsets.UTF_8));
// continue the trace of the request that created the event
Context origin = W3CTraceContextPropagator.getInstance().extract(Context.root(), p, ROW);
try (Scope ignored = origin.makeCurrent()) {
acks.add(kafka.send(record)); // the agent's producer span is a child of the request
}
}
// wait for every ack; on failure the transaction rolls back and the rows are retried
CompletableFuture.allOf(acks.toArray(new CompletableFuture<?>[0])).get(10, TimeUnit.SECONDS);
jdbcTemplate.batchUpdate("UPDATE outbox SET published_at = clock_timestamp() WHERE id = ?",
batch.stream().map(p -> new Object[] {p.id()}).toList());
log.info("relayed {} event(s) to {}", batch.size(), topic);
}
@Scheduled(cron = "0 0 * * * *")
@Transactional
public void purge() {
int n = jdbcTemplate.update(
"DELETE FROM outbox WHERE published_at < now() - interval '1 day'");
log.info("purged {} published outbox row(s)", n);
}
}
The pom.xml adds only io.opentelemetry:opentelemetry-api (the version comes from Spring Boot): without the agent, every call of the API is a no-op, so the service still runs without it.
Rebuild order, place an order and find its trace:
docker compose up -d --build --wait order
curl -s -X POST localhost:8084/orders -H 'Content-Type: application/json' \
-d '{"showId":"show-7","holdId":"trace-1","customerEmail":"t@example.com","amount":10}'
In Grafana → Explore → Tempo, run the TraceQL query {resource.service.name="order" && name="POST /orders"} and open the most recent trace (a new trace can take up to half a minute to appear in the search). You should see one trace with spans of both services, like this one from a test run (start offset, service, span, duration):
0.0 ms order POST /orders 9.0 ms
5.3 ms order INSERT boxoffice.orders 0.4 ms
5.8 ms order SELECT boxoffice.orders 0.2 ms
6.6 ms order INSERT boxoffice.outbox 0.2 ms
22.3 ms order order-events publish 11.2 ms
36.4 ms notification order-events process 67.2 ms
38.1 ms notification INSERT boxoffice.processed_events 0.4 ms
99.7 ms notification INSERT boxoffice.notifications 0.4 ms
The request itself took 9 ms; the relay published 22 ms after it started (its 200 ms polling period puts this anywhere between 0 and 200 ms); notification spent 67 ms, most of it in the simulated 50 ms e-mail provider — the asynchronous latency of lab 7, visible at a glance. The very first order after a restart is much slower (cold JVMs with the agent): look at the second one.
In the interview
"Context propagation breaks at every hop that is not a direct call — queues, outboxes, batch jobs. I carry the W3C traceparent in the message or the row, and the consumer continues the trace; for fan-in (one job processing many messages) I use span links instead of a single parent."