Skip to content

Commit fa0d055

Browse files
feat(infra): add OpenTelemetry distributed tracing across all services (STA-221) (#234)
Add end-to-end distributed tracing using Micrometer Tracing + OpenTelemetry: - TracingConfig: conditional toggle via app.tracing.enabled (default true) - KafkaTracingConfig: BeanPostProcessor enables observation on all KafkaTemplate and KafkaListenerContainerFactory instances for trace context propagation through Kafka message headers (W3C traceparent) - Jaeger all-in-one added to docker-compose.dev.yml (UI on :16686, OTLP HTTP on :4318, OTLP gRPC on :4317) - OTEL collector reference config in infra/local/otel/ for production - 8 new tests covering TracingConfig and KafkaTracingConfig behavior Dependencies already in place (from convention plugin): - micrometer-tracing-bridge-otel - opentelemetry-exporter-otlp All 10 services already configured with: - management.tracing.sampling.probability: 1.0 - management.otlp.tracing.endpoint: http://localhost:4318/v1/traces Closes STA-221 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5d1bc2b commit fa0d055

7 files changed

Lines changed: 290 additions & 0 deletions

File tree

docker-compose.dev.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,24 @@ services:
220220
- ./infra/local/wiremock/__files:/home/wiremock/__files
221221
command: --verbose --global-response-templating
222222

223+
# ─────────────────────────────────────────────
224+
# Jaeger — distributed trace visualization (STA-221)
225+
# ─────────────────────────────────────────────
226+
jaeger:
227+
image: jaegertracing/all-in-one:1.67
228+
container_name: sp-jaeger
229+
ports:
230+
- "16686:16686" # Jaeger UI
231+
- "4317:4317" # OTLP gRPC receiver
232+
- "4318:4318" # OTLP HTTP receiver
233+
environment:
234+
COLLECTOR_OTLP_ENABLED: "true"
235+
healthcheck:
236+
test: ["CMD-SHELL", "wget --spider -q http://localhost:16686/ || exit 1"]
237+
interval: 10s
238+
timeout: 5s
239+
retries: 10
240+
223241
volumes:
224242
pgdata:
225243
tsdata:
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# OpenTelemetry Collector configuration — optional for production deployments.
2+
#
3+
# For LOCAL DEV, services export OTLP directly to Jaeger (which has built-in OTLP support).
4+
# This config is provided as a reference for production where a dedicated collector
5+
# sits between services and the tracing backend (Jaeger, Tempo, Datadog, etc.).
6+
#
7+
# To use in docker-compose, add an otel-collector service:
8+
# otel-collector:
9+
# image: otel/opentelemetry-collector-contrib:0.100.0
10+
# ports:
11+
# - "4317:4317"
12+
# - "4318:4318"
13+
# volumes:
14+
# - ./infra/local/otel/otel-collector-config.yml:/etc/otelcol-contrib/config.yaml
15+
16+
receivers:
17+
otlp:
18+
protocols:
19+
grpc:
20+
endpoint: 0.0.0.0:4317
21+
http:
22+
endpoint: 0.0.0.0:4318
23+
24+
processors:
25+
batch:
26+
timeout: 1s
27+
send_batch_size: 512
28+
29+
exporters:
30+
otlp/jaeger:
31+
endpoint: jaeger:4317
32+
tls:
33+
insecure: true
34+
debug:
35+
verbosity: basic
36+
37+
service:
38+
pipelines:
39+
traces:
40+
receivers: [otlp]
41+
processors: [batch]
42+
exporters: [otlp/jaeger, debug]

platform-infra/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ dependencies {
2121
annotationProcessor("org.projectlombok:lombok")
2222

2323
testImplementation("org.springframework.boot:spring-boot-starter-test")
24+
testImplementation("org.springframework.boot:spring-boot-starter-kafka")
2425
testImplementation("org.springframework.cloud:spring-cloud-starter-openfeign")
2526
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
2627
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.stablecoin.payments.platform.infrastructure.tracing;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.springframework.beans.factory.config.BeanPostProcessor;
5+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
6+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
7+
import org.springframework.context.annotation.Bean;
8+
import org.springframework.context.annotation.Configuration;
9+
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
10+
import org.springframework.kafka.core.KafkaTemplate;
11+
12+
/**
13+
* Enables Micrometer Observation on Kafka producers and consumers so that trace context
14+
* (W3C {@code traceparent}) is automatically propagated through Kafka message headers.
15+
*
16+
* <p>This ensures end-to-end trace visibility across the event-driven pipeline:
17+
* S1 (Orchestrator) &rarr; Kafka &rarr; S2 (Compliance) &rarr; Kafka &rarr; S6 (FX) etc.
18+
*
19+
* <p>Uses a {@link BeanPostProcessor} to enable observation on all {@link KafkaTemplate}
20+
* and {@link ConcurrentKafkaListenerContainerFactory} instances, including those created
21+
* manually by individual services (not just auto-configured beans).
22+
*
23+
* <p>Activated only when both tracing and Kafka are on the classpath.
24+
*/
25+
@Slf4j
26+
@Configuration
27+
@ConditionalOnProperty(name = "app.tracing.enabled", havingValue = "true", matchIfMissing = true)
28+
@ConditionalOnClass(KafkaTemplate.class)
29+
public class KafkaTracingConfig {
30+
31+
@Bean
32+
static KafkaObservationBeanPostProcessor kafkaObservationBeanPostProcessor() {
33+
return new KafkaObservationBeanPostProcessor();
34+
}
35+
36+
/**
37+
* Post-processor that enables observation on all Kafka producer templates and
38+
* consumer container factories discovered in the application context.
39+
*/
40+
static class KafkaObservationBeanPostProcessor implements BeanPostProcessor {
41+
42+
@Override
43+
public Object postProcessAfterInitialization(Object bean, String beanName) {
44+
if (bean instanceof KafkaTemplate<?, ?> template) {
45+
template.setObservationEnabled(true);
46+
log.debug("Enabled observation on KafkaTemplate bean '{}'", beanName);
47+
}
48+
if (bean instanceof ConcurrentKafkaListenerContainerFactory<?, ?> factory) {
49+
factory.getContainerProperties().setObservationEnabled(true);
50+
log.debug("Enabled observation on KafkaListenerContainerFactory bean '{}'", beanName);
51+
}
52+
return bean;
53+
}
54+
}
55+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.stablecoin.payments.platform.infrastructure.tracing;
2+
3+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
4+
import org.springframework.context.annotation.Configuration;
5+
6+
/**
7+
* Enables distributed tracing across all services via Spring Boot auto-configuration.
8+
*
9+
* <p>When {@code app.tracing.enabled=true} (default), Spring Boot auto-configures:
10+
* <ul>
11+
* <li>Micrometer Tracing bridge to OpenTelemetry ({@code micrometer-tracing-bridge-otel})</li>
12+
* <li>OTLP HTTP span exporter to the collector configured via
13+
* {@code management.otlp.tracing.endpoint}</li>
14+
* <li>W3C {@code traceparent} header propagation on Feign/RestClient calls</li>
15+
* <li>Trace context propagation through Kafka message headers when observation is enabled</li>
16+
* <li>MDC population with {@code traceId} and {@code spanId} for structured logging</li>
17+
* </ul>
18+
*
19+
* <p>Set {@code app.tracing.enabled=false} in tests or environments without a collector.
20+
*/
21+
@Configuration
22+
@ConditionalOnProperty(name = "app.tracing.enabled", havingValue = "true", matchIfMissing = true)
23+
public class TracingConfig {
24+
25+
// Spring Boot auto-configures all tracing beans (OtlpHttpSpanExporter, TracerProvider,
26+
// ContextPropagators) when micrometer-tracing-bridge-otel and opentelemetry-exporter-otlp
27+
// are on the classpath. No manual bean definitions needed.
28+
//
29+
// Key application properties driving behavior:
30+
// management.tracing.sampling.probability — sampling rate (1.0 = 100% in dev)
31+
// management.otlp.tracing.endpoint — OTLP collector HTTP endpoint
32+
//
33+
// This @Configuration class exists to:
34+
// 1. Provide a single toggle (app.tracing.enabled) to disable all tracing
35+
// 2. Document the tracing architecture for developers
36+
// 3. Serve as an extension point for custom SpanProcessor/SpanExporter beans
37+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package com.stablecoin.payments.platform.infrastructure.tracing;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.junit.jupiter.api.Test;
5+
import org.springframework.boot.autoconfigure.AutoConfigurations;
6+
import org.springframework.boot.kafka.autoconfigure.KafkaAutoConfiguration;
7+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
8+
import org.springframework.kafka.core.KafkaTemplate;
9+
10+
import java.lang.reflect.Field;
11+
12+
import static org.assertj.core.api.Assertions.assertThat;
13+
14+
class KafkaTracingConfigTest {
15+
16+
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
17+
.withConfiguration(AutoConfigurations.of(
18+
KafkaAutoConfiguration.class,
19+
KafkaTracingConfig.class))
20+
.withPropertyValues("spring.kafka.bootstrap-servers=localhost:9092");
21+
22+
@Test
23+
@DisplayName("should enable observation on KafkaTemplate when tracing is enabled")
24+
void shouldEnableObservationOnKafkaTemplate() {
25+
contextRunner
26+
.withPropertyValues("app.tracing.enabled=true")
27+
.run(context -> {
28+
assertThat(context).hasSingleBean(KafkaTemplate.class);
29+
var template = context.getBean(KafkaTemplate.class);
30+
assertThat(isObservationEnabled(template)).isTrue();
31+
});
32+
}
33+
34+
@Test
35+
@DisplayName("should enable observation by default when app.tracing.enabled is absent")
36+
void shouldEnableObservationByDefault() {
37+
contextRunner
38+
.run(context -> {
39+
assertThat(context).hasSingleBean(KafkaTemplate.class);
40+
var template = context.getBean(KafkaTemplate.class);
41+
assertThat(isObservationEnabled(template)).isTrue();
42+
});
43+
}
44+
45+
@Test
46+
@DisplayName("should not enable observation when app.tracing.enabled is false")
47+
void shouldNotEnableObservationWhenTracingDisabled() {
48+
new ApplicationContextRunner()
49+
.withConfiguration(AutoConfigurations.of(
50+
KafkaAutoConfiguration.class,
51+
KafkaTracingConfig.class))
52+
.withPropertyValues(
53+
"spring.kafka.bootstrap-servers=localhost:9092",
54+
"app.tracing.enabled=false")
55+
.run(context -> {
56+
assertThat(context).doesNotHaveBean(KafkaTracingConfig.class);
57+
assertThat(context).hasSingleBean(KafkaTemplate.class);
58+
var template = context.getBean(KafkaTemplate.class);
59+
assertThat(isObservationEnabled(template)).isFalse();
60+
});
61+
}
62+
63+
@Test
64+
@DisplayName("should register BeanPostProcessor when tracing is enabled")
65+
void shouldRegisterBeanPostProcessor() {
66+
contextRunner
67+
.run(context ->
68+
assertThat(context).hasSingleBean(
69+
KafkaTracingConfig.KafkaObservationBeanPostProcessor.class));
70+
}
71+
72+
@Test
73+
@DisplayName("should not register BeanPostProcessor when tracing is disabled")
74+
void shouldNotRegisterBeanPostProcessorWhenDisabled() {
75+
new ApplicationContextRunner()
76+
.withConfiguration(AutoConfigurations.of(
77+
KafkaAutoConfiguration.class,
78+
KafkaTracingConfig.class))
79+
.withPropertyValues(
80+
"spring.kafka.bootstrap-servers=localhost:9092",
81+
"app.tracing.enabled=false")
82+
.run(context ->
83+
assertThat(context).doesNotHaveBean(
84+
KafkaTracingConfig.KafkaObservationBeanPostProcessor.class));
85+
}
86+
87+
/**
88+
* Reads the private {@code observationEnabled} field via reflection since
89+
* {@link KafkaTemplate} exposes a setter but no getter for this flag.
90+
*/
91+
private static boolean isObservationEnabled(KafkaTemplate<?, ?> template) {
92+
try {
93+
Field field = KafkaTemplate.class.getDeclaredField("observationEnabled");
94+
field.setAccessible(true);
95+
return (boolean) field.get(template);
96+
} catch (NoSuchFieldException | IllegalAccessException e) {
97+
throw new RuntimeException("Failed to read observationEnabled field", e);
98+
}
99+
}
100+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.stablecoin.payments.platform.infrastructure.tracing;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.junit.jupiter.api.Test;
5+
import org.springframework.boot.autoconfigure.AutoConfigurations;
6+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
7+
8+
import static org.assertj.core.api.Assertions.assertThat;
9+
10+
class TracingConfigTest {
11+
12+
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
13+
.withConfiguration(AutoConfigurations.of(TracingConfig.class));
14+
15+
@Test
16+
@DisplayName("should register TracingConfig when app.tracing.enabled is true")
17+
void shouldRegisterTracingConfigWhenEnabled() {
18+
contextRunner
19+
.withPropertyValues("app.tracing.enabled=true")
20+
.run(context -> assertThat(context).hasSingleBean(TracingConfig.class));
21+
}
22+
23+
@Test
24+
@DisplayName("should register TracingConfig when app.tracing.enabled property is absent (default)")
25+
void shouldRegisterTracingConfigWhenPropertyAbsent() {
26+
contextRunner
27+
.run(context -> assertThat(context).hasSingleBean(TracingConfig.class));
28+
}
29+
30+
@Test
31+
@DisplayName("should not register TracingConfig when app.tracing.enabled is false")
32+
void shouldNotRegisterTracingConfigWhenDisabled() {
33+
contextRunner
34+
.withPropertyValues("app.tracing.enabled=false")
35+
.run(context -> assertThat(context).doesNotHaveBean(TracingConfig.class));
36+
}
37+
}

0 commit comments

Comments
 (0)