附录:随书实验完整代码
以下文件在构建时从随书实验与测试目录读取,与下载区实验包同源。它们是本书教学实现;实际验证范围以实验章节和验证记录为准。
lab/compose.yaml
name: observability-book
services:
redis:
image: library/redis:7.2.5-alpine@sha256:6aaf3f5e6bc8a592fbfe2cccf19eb36d27c39d12dab4f4b01556b7449e7b1f44
ports: ["127.0.0.1:6379:6379"]
namesrv:
image: apache/rocketmq:5.3.2@sha256:4a80ff5cd4b0a3d3ef6dc338ce2028a85bd995648cb3aa5c3e753746d2dbc587
command: ["sh", "mqnamesrv"]
environment:
JAVA_OPT_EXT: "-Xms128m -Xmx128m -Xmn64m"
ports: ["127.0.0.1:9876:9876"]
broker:
image: apache/rocketmq:5.3.2@sha256:4a80ff5cd4b0a3d3ef6dc338ce2028a85bd995648cb3aa5c3e753746d2dbc587
command: ["sh", "mqbroker", "-n", "namesrv:9876", "-c", "/home/rocketmq/rocketmq-5.3.2/conf/broker.conf"]
environment:
JAVA_OPT_EXT: "-Xms256m -Xmx256m -Xmn128m"
volumes: ["./config/broker.conf:/home/rocketmq/rocketmq-5.3.2/conf/broker.conf:ro"]
ports: ["127.0.0.1:10911:10911", "127.0.0.1:10909:10909"]
depends_on: [namesrv]
tempo:
image: grafana/tempo:2.6.1@sha256:ef4384fce6e8ad22b95b243d8fc165628cda655376fd50e7850536ad89d71d50
command: ["-config.file=/etc/tempo.yaml"]
volumes: ["./config/tempo.yaml:/etc/tempo.yaml:ro"]
ports: ["127.0.0.1:3200:3200"]
collector:
image: otel/opentelemetry-collector-contrib:0.111.0@sha256:a2a52e43c1a80aa94120ad78c2db68780eb90e6d11c8db5b3ce2f6a0cc6b5029
command: ["--config=/etc/collector.yaml"]
volumes: ["./config/collector.yaml:/etc/collector.yaml:ro"]
ports: ["127.0.0.1:4318:4318", "127.0.0.1:8889:8889", "127.0.0.1:13133:13133"]
depends_on: [tempo]
prometheus:
image: prom/prometheus:v2.55.1@sha256:2659f4c2ebb718e7695cb9b25ffa7d6be64db013daba13e05c875451cf51b0d3
command: ["--config.file=/etc/prometheus/prometheus.yaml", "--storage.tsdb.path=/prometheus", "--enable-feature=exemplar-storage"]
volumes: ["./config/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro", "./config/alerts.yaml:/etc/prometheus/alerts.yaml:ro"]
ports: ["127.0.0.1:9095:9090"]
depends_on: [collector]lab/config/alerts.yaml
groups:
- name: book-slo
rules:
- alert: BookFastBurn
expr: (sum(rate(lab_requests_total{service_name="book-gateway",outcome="error"}[1m])) / sum(rate(lab_requests_total{service_name="book-gateway"}[1m])) > 0.05) and (sum(increase(lab_requests_total{service_name="book-gateway"}[1m])) >= 5)
for: 2s
labels:
severity: exercise
annotations:
summary: "实验请求错误率超过5%,为99%可用性目标的5倍预算消耗率"lab/config/broker.conf
brokerClusterName=BookCluster
brokerName=book-broker
brokerId=0
brokerIP1=127.0.0.1
autoCreateTopicEnable=true
flushDiskType=SYNC_FLUSH
brokerRole=ASYNC_MASTER
lab/config/collector.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 200
spike_limit_mib: 50
batch:
timeout: 1s
send_batch_size: 128
exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
prometheus:
enable_open_metrics: true
endpoint: 0.0.0.0:8889
resource_to_telemetry_conversion:
enabled: true
debug:
verbosity: detailed
extensions:
health_check:
endpoint: 0.0.0.0:13133
service:
extensions: [health_check]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [prometheus]
logs:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [debug]lab/config/prometheus.yaml
global:
scrape_interval: 1s
evaluation_interval: 1s
rule_files: ["/etc/prometheus/alerts.yaml"]
scrape_configs:
- job_name: book-collector
static_configs:
- targets: ["collector:8889"]lab/config/tempo.yaml
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
ingester:
trace_idle_period: 2s
max_block_duration: 1m
compactor:
compaction:
block_retention: 1h
storage:
trace:
backend: local
wal:
path: /tmp/tempo/wal
local:
path: /tmp/tempo/blockslab/pom.xml
<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><groupId>book.observability</groupId><artifactId>observability-lab</artifactId><version>1.0.0</version>
<properties><maven.compiler.release>21</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><otel.version>1.55.0</otel.version></properties>
<dependencies>
<dependency><groupId>io.opentelemetry</groupId><artifactId>opentelemetry-sdk</artifactId><version>${otel.version}</version></dependency>
<dependency><groupId>io.opentelemetry</groupId><artifactId>opentelemetry-exporter-otlp</artifactId><version>${otel.version}</version></dependency>
<dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId><version>5.2.0</version></dependency>
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-client</artifactId><version>5.3.2</version></dependency>
<dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId><version>2.17.2</version></dependency>
<dependency><groupId>org.slf4j</groupId><artifactId>slf4j-simple</artifactId><version>2.0.16</version></dependency>
</dependencies>
<build><finalName>observability-lab</finalName><plugins>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.13.0</version></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-shade-plugin</artifactId><version>3.6.0</version><executions><execution><phase>package</phase><goals><goal>shade</goal></goals><configuration><createDependencyReducedPom>false</createDependencyReducedPom><filters><filter><artifact>*:*</artifact><excludes><exclude>META-INF/*.SF</exclude><exclude>META-INF/*.DSA</exclude><exclude>META-INF/*.RSA</exclude></excludes></filter></filters><transformers><transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/><transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"><mainClass>book.SystemLab</mainClass></transformer></transformers></configuration></execution></executions></plugin>
</plugins></build>
</project>lab/run_ci.sh
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p evidence
compose=(docker compose -f lab/compose.yaml)
app_pid=
cleanup() {
if [[ -n "$app_pid" ]]; then kill "$app_pid" 2>/dev/null || true; wait "$app_pid" 2>/dev/null || true; fi
"${compose[@]}" logs --no-color > evidence/containers.log 2>&1 || true
"${compose[@]}" ps -a > evidence/containers-status.txt || true
"${compose[@]}" down -v --remove-orphans || true
}
trap cleanup EXIT
git rev-parse HEAD > evidence/commit.txt
java -version 2> evidence/java-version.txt
"${compose[@]}" config --images > evidence/images.txt
"${compose[@]}" up -d
topic_ready=0
for ((attempt=0;attempt<90;attempt++)); do
"${compose[@]}" exec -T broker sh -c 'sh /home/rocketmq/rocketmq-5.3.2/bin/mqadmin updateTopic -n namesrv:9876 -b 127.0.0.1:10911 -t otel-book-orders' > evidence/topic.txt 2>&1 || true
if grep -qi 'create topic.*success' evidence/topic.txt; then
"${compose[@]}" exec -T broker sh -c 'sh /home/rocketmq/rocketmq-5.3.2/bin/mqadmin topicRoute -n namesrv:9876 -t otel-book-orders' > evidence/topic-route.json 2>&1 || true
if grep -q 'brokerAddrs' evidence/topic-route.json; then topic_ready=1; break; fi
fi
sleep 2
done
[[ "$topic_ready" == 1 ]] || { cat evidence/topic.txt; exit 1; }
"${compose[@]}" exec -T redis redis-cli PING > evidence/redis-ping.txt
java -jar lab/target/observability-lab.jar > evidence/application.log 2>&1 &
app_pid=$!
python3 tests/verify_system.py --phase full
"${compose[@]}" logs --no-color collector > evidence/collector.log
python3 - <<'PY'
import json
from pathlib import Path
results=json.loads(Path('evidence/results.json').read_text())
trace=results['checks']['normal']['response']['trace_id']
logs=Path('evidence/collector.log').read_text().lower()
assert trace.lower() in logs, 'Collector did not receive trace-correlated OTLP logs'
print('PASS actual OTLP logs contain the same trace id as Tempo spans')
PY
"${compose[@]}" stop collector
python3 tests/verify_system.py --phase collector-outage
"${compose[@]}" start collector
python3 tests/verify_system.py --phase recovered
kill "$app_pid"
wait "$app_pid" || true
app_pid=
SAMPLER_RATIO=0 java -jar lab/target/observability-lab.jar > evidence/application-sampling-zero.log 2>&1 &
app_pid=$!
python3 tests/verify_system.py --phase sampled-out
"${compose[@]}" exec -T redis redis-cli --scan --pattern 'otelbook:*' > evidence/redis-keys.txtlab/src/main/java/book/SystemLab.java
package book;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.logs.Severity;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.Scope;
import io.opentelemetry.context.propagation.TextMapGetter;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageExt;
import redis.clients.jedis.JedisPooled;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.Duration;
import java.util.Map;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.Executors;
/** A deliberately small real HTTP -> Redis -> RocketMQ -> Redis system. */
public final class SystemLab {
static final ObjectMapper JSON = new ObjectMapper();
static final Telemetry GATEWAY = new Telemetry("book-gateway");
static final Telemetry ORDERS = new Telemetry("book-orders");
static final Telemetry WORKER = new Telemetry("book-worker");
static final JedisPooled REDIS = new JedisPooled(System.getenv().getOrDefault("REDIS_HOST", "127.0.0.1"), 6379);
static final DefaultMQProducer PRODUCER = new DefaultMQProducer("otel-book-producer");
static final DefaultMQPushConsumer CONSUMER = new DefaultMQPushConsumer("otel-book-worker");
static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
static final ConcurrentLinkedDeque<Map<String,Object>> EVENTS = new ConcurrentLinkedDeque<>();
static final String TOPIC = "otel-book-orders";
static final TextMapGetter<HttpExchange> HTTP_GETTER = new TextMapGetter<>() {
public Iterable<String> keys(HttpExchange exchange) { return exchange.getRequestHeaders().keySet(); }
public String get(HttpExchange exchange, String key) { return exchange.getRequestHeaders().getFirst(key); }
};
static final TextMapGetter<MessageExt> MQ_GETTER = new TextMapGetter<>() {
public Iterable<String> keys(MessageExt message) { return message.getProperties().keySet(); }
public String get(MessageExt message, String key) { return message.getProperty(key); }
};
static byte[] bytes(Object value) throws Exception { return JSON.writeValueAsBytes(value); }
static void respond(HttpExchange exchange, int status, Object body) throws Exception {
byte[] raw = bytes(body);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.getResponseHeaders().set("X-Trace-Id", Span.current().getSpanContext().getTraceId());
exchange.sendResponseHeaders(status, raw.length);
exchange.getResponseBody().write(raw);
}
static void event(Telemetry telemetry, String name, String id) {
Map<String,Object> row = new LinkedHashMap<>();
row.put("timestamp", Instant.now().toString());row.put("service",telemetry.service);row.put("event",name);
row.put("order_id",id);row.put("trace_id",Span.current().getSpanContext().getTraceId());
row.put("span_id",Span.current().getSpanContext().getSpanId());row.put("sampled",Span.current().getSpanContext().isSampled());
EVENTS.add(row);while(EVENTS.size()>300)EVENTS.pollFirst();
try { System.out.println(JSON.writeValueAsString(row)); } catch(Exception error) { throw new RuntimeException(error); }
telemetry.logger.logRecordBuilder().setContext(Context.current()).setSeverity(Severity.INFO)
.setBody(name).setAttribute("order.id",id).emit();
}
static int delay(JsonNode body, String name) {
int value=body.path(name).asInt(0);
if(value<0||value>1500)throw new IllegalArgumentException("Delay outside 0..1500 ms");
return value;
}
static void consume(MessageExt message) throws Exception {
Context parent = WORKER.sdk.getPropagators().getTextMapPropagator().extract(Context.root(),message,MQ_GETTER);
Span span = WORKER.tracer.spanBuilder("rocketmq process " + TOPIC).setSpanKind(SpanKind.CONSUMER)
.setParent(parent).setAttribute("messaging.system","rocketmq").setAttribute("messaging.destination.name",TOPIC).startSpan();
long start=System.nanoTime();
try(Scope scope=span.makeCurrent()) {
JsonNode body=JSON.readTree(message.getBody());String id=body.path("id").asText();
span.setAttribute("order.id",id);Thread.sleep(body.path("worker_delay_ms").asInt());
Span redis=WORKER.tracer.spanBuilder("redis SET processed").setSpanKind(SpanKind.CLIENT).startSpan();
try(Scope ignored=redis.makeCurrent()) {REDIS.setex("otelbook:processed:"+id,3600,JSON.writeValueAsString(Map.of("id",id,"trace_id",span.getSpanContext().getTraceId())));}
finally {redis.end();}
event(WORKER,"worker.processed",id);
WORKER.requests.add(1,Attributes.builder().put("route","message").put("outcome","ok").build());
} catch(Exception error) {span.recordException(error);span.setStatus(StatusCode.ERROR);throw error;}
finally {WORKER.duration.record((System.nanoTime()-start)/1e9,Attributes.of(io.opentelemetry.api.common.AttributeKey.stringKey("route"),"message"),Context.root().with(span));span.end();}
}
static void order(HttpExchange exchange) {
Context parent=ORDERS.sdk.getPropagators().getTextMapPropagator().extract(Context.root(),exchange,HTTP_GETTER);
Span span=ORDERS.tracer.spanBuilder("orders " + exchange.getRequestMethod() + " /orders").setParent(parent)
.setSpanKind(SpanKind.SERVER).setAttribute("http.route","/orders").startSpan();
long start=System.nanoTime();String outcome="ok";
try(Scope scope=span.makeCurrent()) {
if(exchange.getRequestMethod().equals("GET")) {
String id=exchange.getRequestURI().getPath().replaceFirst("^/orders/","");
Span redis=ORDERS.tracer.spanBuilder("redis GET order").setSpanKind(SpanKind.CLIENT).startSpan();
String value;try(Scope ignored=redis.makeCurrent()){value=REDIS.get("otelbook:order:"+id);}finally{redis.end();}
respond(exchange,value==null?404:200,Map.of("order_id",id,"found",value!=null));return;
}
JsonNode body=JSON.readTree(exchange.getRequestBody());String id=body.path("id").asText();
if(!id.matches("[a-z0-9-]{1,40}"))throw new IllegalArgumentException("Use a short lowercase id");
int redisDelay=delay(body,"redis_delay_ms"),workerDelay=delay(body,"worker_delay_ms");span.setAttribute("order.id",id);
if(body.path("fail").asBoolean(false)) {outcome="error";span.setStatus(StatusCode.ERROR,"injected application failure");event(ORDERS,"order.failed",id);respond(exchange,503,Map.of("error","injected-application-error","trace_id",span.getSpanContext().getTraceId()));return;}
Span redis=ORDERS.tracer.spanBuilder("redis SET order").setSpanKind(SpanKind.CLIENT)
.setAttribute("db.system","redis").setAttribute("fault.injected_delay_ms",redisDelay).startSpan();
try(Scope ignored=redis.makeCurrent()){Thread.sleep(redisDelay);REDIS.setex("otelbook:order:"+id,3600,body.toString());}finally{redis.end();}
Span producer=ORDERS.tracer.spanBuilder("rocketmq publish " + TOPIC).setSpanKind(SpanKind.PRODUCER)
.setAttribute("messaging.system","rocketmq").setAttribute("messaging.destination.name",TOPIC).startSpan();
try(Scope ignored=producer.makeCurrent()) {
Message message=new Message(TOPIC,"created",id,bytes(Map.of("id",id,"worker_delay_ms",workerDelay)));
if(body.path("propagate").asBoolean(true)) ORDERS.sdk.getPropagators().getTextMapPropagator()
.inject(Context.current(),message,(carrier,key,value)->carrier.putUserProperty(key,value));
PRODUCER.send(message);
} finally {producer.end();}
event(ORDERS,"order.queued",id);respond(exchange,202,Map.of("order_id",id,"state","queued","trace_id",span.getSpanContext().getTraceId()));
} catch(Exception error) {error.printStackTrace();outcome="error";span.recordException(error);span.setStatus(StatusCode.ERROR);try{respond(exchange,500,Map.of("error",error.getClass().getSimpleName()));}catch(Exception ignored){}}
finally {ORDERS.requests.add(1,Attributes.builder().put("route","/orders").put("outcome",outcome).build());ORDERS.duration.record((System.nanoTime()-start)/1e9,Attributes.of(io.opentelemetry.api.common.AttributeKey.stringKey("route"),"/orders"),Context.root().with(span));span.end();exchange.close();}
}
static void gateway(HttpExchange exchange) {
try {
if(exchange.getRequestURI().getPath().equals("/health")){respond(exchange,200,Map.of("status","ready"));exchange.close();return;}
if(exchange.getRequestURI().getPath().equals("/lab/events")){respond(exchange,200,List.copyOf(EVENTS));exchange.close();return;}
if(exchange.getRequestURI().getPath().equals("/lab/flush")){GATEWAY.flush();ORDERS.flush();WORKER.flush();respond(exchange,200,Map.of("flushed",true));exchange.close();return;}
} catch(Exception error){exchange.close();return;}
Context parent=GATEWAY.sdk.getPropagators().getTextMapPropagator().extract(Context.root(),exchange,HTTP_GETTER);
Span span=GATEWAY.tracer.spanBuilder("gateway " + exchange.getRequestMethod() + " /orders").setSpanKind(SpanKind.SERVER)
.setParent(parent).setAttribute("http.route","/orders").startSpan();long start=System.nanoTime();String outcome="ok";
try(Scope scope=span.makeCurrent()) {
Span client=GATEWAY.tracer.spanBuilder("gateway -> orders").setSpanKind(SpanKind.CLIENT).startSpan();
HttpResponse<String> response;
try(Scope ignored=client.makeCurrent()) {
byte[] body=exchange.getRequestBody().readAllBytes();
HttpRequest.Builder request=HttpRequest.newBuilder(URI.create("http://127.0.0.1:8081"+exchange.getRequestURI()))
.timeout(Duration.ofSeconds(5)).header("Content-Type","application/json")
.method(exchange.getRequestMethod(),HttpRequest.BodyPublishers.ofByteArray(body));
GATEWAY.sdk.getPropagators().getTextMapPropagator().inject(Context.current(),request,(carrier,key,value)->carrier.header(key,value));
response=HTTP.send(request.build(),HttpResponse.BodyHandlers.ofString());
if(response.statusCode()>=500)client.setStatus(StatusCode.ERROR);
} finally {client.end();}
if(response.statusCode()>=500){span.setStatus(StatusCode.ERROR);outcome="error";}
JsonNode json=JSON.readTree(response.body());event(GATEWAY,"gateway.request",json.path("order_id").asText("unknown"));respond(exchange,response.statusCode(),json);
} catch(Exception error){span.recordException(error);span.setStatus(StatusCode.ERROR);outcome="error";try{respond(exchange,502,Map.of("error",error.getClass().getSimpleName()));}catch(Exception ignored){}}
finally {GATEWAY.requests.add(1,Attributes.builder().put("route","/orders").put("outcome",outcome).build());GATEWAY.duration.record((System.nanoTime()-start)/1e9,Attributes.of(io.opentelemetry.api.common.AttributeKey.stringKey("route"),"/orders"),Context.root().with(span));span.end();exchange.close();}
}
public static void main(String[] args) throws Exception {
String namesrv=System.getenv().getOrDefault("NAMESRV_ADDR","127.0.0.1:9876");REDIS.ping();
PRODUCER.setNamesrvAddr(namesrv);PRODUCER.setSendMsgTimeout(10000);PRODUCER.start();
CONSUMER.setNamesrvAddr(namesrv);CONSUMER.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
CONSUMER.setConsumeThreadMin(1);CONSUMER.setConsumeThreadMax(1);CONSUMER.subscribe(TOPIC,"*");
CONSUMER.registerMessageListener((MessageListenerConcurrently)(messages,context)->{
try {for(MessageExt message:messages)consume(message);return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;}
catch(Exception error){error.printStackTrace();return ConsumeConcurrentlyStatus.RECONSUME_LATER;}
});CONSUMER.start();
HttpServer orders=HttpServer.create(new InetSocketAddress("127.0.0.1",8081),0);orders.createContext("/orders",SystemLab::order);orders.setExecutor(Executors.newVirtualThreadPerTaskExecutor());orders.start();
HttpServer gateway=HttpServer.create(new InetSocketAddress("127.0.0.1",8080),0);gateway.createContext("/",SystemLab::gateway);gateway.setExecutor(Executors.newVirtualThreadPerTaskExecutor());gateway.start();
Runtime.getRuntime().addShutdownHook(new Thread(()->{gateway.stop(0);orders.stop(0);CONSUMER.shutdown();PRODUCER.shutdown();GATEWAY.close();ORDERS.close();WORKER.close();REDIS.close();}));
System.out.println("READY http://127.0.0.1:8080");
}
}lab/src/main/java/book/Telemetry.java
package book;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.logs.Logger;
import io.opentelemetry.api.metrics.DoubleHistogram;
import io.opentelemetry.api.metrics.LongCounter;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.propagation.ContextPropagators;
import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator;
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter;
import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter;
import io.opentelemetry.sdk.OpenTelemetrySdk;
import io.opentelemetry.sdk.resources.Resource;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor;
import io.opentelemetry.sdk.trace.samplers.Sampler;
import io.opentelemetry.sdk.metrics.SdkMeterProvider;
import io.opentelemetry.sdk.metrics.InstrumentSelector;
import io.opentelemetry.sdk.metrics.View;
import io.opentelemetry.sdk.metrics.Aggregation;
import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader;
import io.opentelemetry.sdk.logs.SdkLoggerProvider;
import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.TimeUnit;
/** Three real OTLP signals; no in-memory tracing substitute. */
public final class Telemetry implements AutoCloseable {
public final String service;
public final OpenTelemetrySdk sdk;
public final Tracer tracer;
public final Logger logger;
public final LongCounter requests;
public final DoubleHistogram duration;
public Telemetry(String service) {
this.service = service;
String endpoint = System.getenv().getOrDefault("COLLECTOR_URL", "http://127.0.0.1:4318");
Resource resource = Resource.create(Attributes.builder().put("service.name", service)
.put("service.version", "1.0.0").put("deployment.environment.name", "book-lab").build());
double ratio = Double.parseDouble(System.getenv().getOrDefault("SAMPLER_RATIO", "1.0"));
SdkTracerProvider traces = SdkTracerProvider.builder().setResource(resource)
.setSampler(Sampler.parentBased(Sampler.traceIdRatioBased(ratio)))
.addSpanProcessor(BatchSpanProcessor.builder(OtlpHttpSpanExporter.builder()
.setEndpoint(endpoint + "/v1/traces").build()).setScheduleDelay(Duration.ofMillis(250)).build()).build();
SdkMeterProvider metrics = SdkMeterProvider.builder().setResource(resource)
.registerView(InstrumentSelector.builder().setName("lab.http.duration").build(), View.builder()
.setAggregation(Aggregation.explicitBucketHistogram(List.of(.005,.01,.025,.05,.1,.25,.5,1.0,2.0,5.0))).build())
.registerMetricReader(PeriodicMetricReader.builder(OtlpHttpMetricExporter.builder()
.setEndpoint(endpoint + "/v1/metrics").build()).setInterval(Duration.ofSeconds(1)).build()).build();
SdkLoggerProvider logs = SdkLoggerProvider.builder().setResource(resource)
.addLogRecordProcessor(BatchLogRecordProcessor.builder(OtlpHttpLogRecordExporter.builder()
.setEndpoint(endpoint + "/v1/logs").build()).setScheduleDelay(Duration.ofMillis(250)).build()).build();
sdk = OpenTelemetrySdk.builder().setTracerProvider(traces).setMeterProvider(metrics).setLoggerProvider(logs)
.setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())).build();
tracer = sdk.getTracer("book.observability", "1.0.0");
logger = sdk.getLogsBridge().get("book.observability");
requests = sdk.getMeter("book.observability").counterBuilder("lab.requests").setUnit("{request}").build();
duration = sdk.getMeter("book.observability").histogramBuilder("lab.http.duration").setUnit("s").build();
for (String outcome : List.of("ok", "error")) requests.add(0, Attributes.builder().put("route","/orders").put("outcome",outcome).build());
}
public void flush() {
sdk.getSdkTracerProvider().forceFlush().join(10, TimeUnit.SECONDS);
sdk.getSdkMeterProvider().forceFlush().join(10, TimeUnit.SECONDS);
sdk.getSdkLoggerProvider().forceFlush().join(10, TimeUnit.SECONDS);
}
public void close() { sdk.close(); }
}tests/verify_system.py
#!/usr/bin/env python3
"""Query actual backends and preserve raw evidence. No in-memory trace oracle."""
import argparse,base64,json,time,urllib.request,urllib.error,urllib.parse
from pathlib import Path
OUT=Path('evidence');OUT.mkdir(exist_ok=True)
def request(url,body=None):
data=json.dumps(body).encode() if body is not None else None
try:
with urllib.request.urlopen(urllib.request.Request(url,data=data,headers={'Content-Type':'application/json','Accept':'application/json'}),timeout=15) as response:
raw=response.read();return response.status,json.loads(raw) if raw else None
except urllib.error.HTTPError as error:
raw=error.read()
try:value=json.loads(raw)
except Exception:value=raw.decode(errors='replace')
return error.code,value
def wait(label,fn,timeout=75):
start=time.monotonic();last=None
while time.monotonic()-start<timeout:
try:
last=fn()
if last:return last
except Exception as error:last=str(error)
time.sleep(1)
raise AssertionError(f'{label}: timeout; last={last}')
def save(name,data):(OUT/name).write_text(json.dumps(data,ensure_ascii=False,indent=2)+'\n')
def events():return request('http://127.0.0.1:8080/lab/events')[1]
def processed(order):return next((e for e in events() if e['event']=='worker.processed' and e['order_id']==order),None)
def query(expression):
status,result=request('http://127.0.0.1:9095/api/v1/query?'+urllib.parse.urlencode({'query':expression}))
assert status==200,(status,result)
return result
def identifier(value):
if not value:return ''
if len(value) in (16,32) and all(c in '0123456789abcdefABCDEF' for c in value):return value.lower()
return base64.b64decode(value).hex()
def spans(trace):
result=[]
def walk(value,service=''):
if isinstance(value,dict):
if 'resource' in value:
attrs=value['resource'].get('attributes',[])
service=next((a['value'].get('stringValue','') for a in attrs if a['key']=='service.name'),service)
if 'spanId' in value and 'name' in value:
result.append(dict(name=value['name'],service=service,span_id=identifier(value['spanId']),parent=identifier(value.get('parentSpanId','')),start=int(value.get('startTimeUnixNano',0)),end=int(value.get('endTimeUnixNano',0))))
for child in value.values():walk(child,service)
elif isinstance(value,list):
for child in value:walk(child,service)
walk(trace);return result
def trace(trace_id,name,services=None,minimum=7):
required=services or {'book-gateway','book-orders','book-worker'}
observations=[]
def fetch():
status,value=request('http://127.0.0.1:3200/api/traces/'+trace_id)
found=spans(value) if status==200 else []
observations.append({'status':status,'spans':len(found),'services':sorted({s['service'] for s in found})})
return value if status==200 and len(found)>=minimum and required<={s['service'] for s in found} else None
result=wait('Tempo complete trace '+name,fetch);save('trace-arrival-'+name+'.json',observations);save('trace-'+name+'.json',result);return spans(result)
def post(order,**extra):
status,body=request('http://127.0.0.1:8080/orders',dict(id=order,**extra))
return status,body
def main():
ap=argparse.ArgumentParser();ap.add_argument('--phase',choices=['full','sampled-out','collector-outage','recovered'],default='full');args=ap.parse_args()
wait('application ready',lambda:request('http://127.0.0.1:8080/health')[0]==200)
if args.phase=='collector-outage':
start=time.monotonic();status,body=post('collector-outage');elapsed=time.monotonic()-start
assert status==202 and elapsed<8,(status,body,elapsed)
event=wait('business with collector stopped',lambda:processed('collector-outage'))
save('collector-outage.json',{'response':body,'worker_event':event,'http_elapsed_seconds':elapsed})
print('PASS application and real MQ consumer continue while Collector is stopped');return
if args.phase=='recovered':
wait('Collector health after restart',lambda:request('http://127.0.0.1:13133/')[0]==200)
status,body=post('after-outage');assert status==202,body
event=wait('after-outage consumer',lambda:processed('after-outage'))
found=trace(body['trace_id'],'after-outage');assert {'book-gateway','book-orders','book-worker'}<={s['service'] for s in found}
outage=json.loads((OUT/'collector-outage.json').read_text())
old_code,old_body=request('http://127.0.0.1:3200/api/traces/'+outage['response']['trace_id'])
save('collector-recovered.json',{'fresh_response':body,'fresh_worker':event,'fresh_trace_services':sorted({s['service'] for s in found}),'outage_trace_status_after_recovery':old_code})
print('PASS fresh real traces resume after Collector restart; old-trace result recorded without promising lossless recovery');return
if args.phase=='sampled-out':
status,body=post('sampled-out');assert status==202,body
event=wait('unsampled consumer',lambda:processed('sampled-out'))
assert event['sampled'] is False,event
request('http://127.0.0.1:8080/lab/flush');time.sleep(5)
code,value=request('http://127.0.0.1:3200/api/traces/'+body['trace_id'])
assert code==404,(code,value)
result={'sampled_out_order':body,'worker_event':event,'tempo_http_status':code,'metrics':query('sum(lab_requests_total{service_name="book-gateway"})')}
assert result['metrics']['data']['result'],result
save('sampling-zero.json',result);print('PASS sampling=0: real consumer and metrics continue; Tempo returns 404; logs mark sampled=false');return
wait('initial metric series',lambda:query('sum(lab_requests_total{service_name="book-gateway"})')['data']['result'])
time.sleep(3)
results={}
for name,options in [('normal',{}),('redis-slow',{'redis_delay_ms':350}),('worker-slow',{'worker_delay_ms':450}),('broken-context',{'propagate':False})]:
status,body=post(name,**options);assert status==202,(status,body)
event=wait('worker '+name,lambda:processed(name))
results[name]={'response':body,'worker_event':event}
found=trace(body['trace_id'],name,{'book-gateway','book-orders'} if name=='broken-context' else None,5 if name=='broken-context' else 7)
assert {'book-gateway','book-orders'}<={s['service'] for s in found},found
if name!='broken-context':
assert event['trace_id']==body['trace_id'],(name,event,body)
assert 'book-worker' in {s['service'] for s in found},found
producer=next(s for s in found if s['name'].startswith('rocketmq publish'))
consumer=next(s for s in found if s['name'].startswith('rocketmq process'))
assert consumer['parent']==producer['span_id'],(producer,consumer)
assert any(s['span_id']==event['span_id'] for s in found),event
else:
assert event['trace_id']!=body['trace_id'],(event,body)
assert 'book-worker' not in {s['service'] for s in found}
detached=trace(event['trace_id'],'detached-worker',{'book-worker'},2)
assert 'book-worker' in {s['service'] for s in detached}
if name=='redis-slow':
client=next(s for s in found if s['name']=='redis SET order')
assert (client['end']-client['start'])/1e6>=300,client
results[name]['injected_span_ms']=(client['end']-client['start'])/1e6
if name=='worker-slow':
consumer=next(s for s in found if s['name'].startswith('rocketmq process'))
assert (consumer['end']-consumer['start'])/1e6>=400,consumer
results[name]['consumer_span_ms']=(consumer['end']-consumer['start'])/1e6
results[name]['spans']=found
for number in range(7):
status,body=post('slo-ok-'+str(number));assert status==202,body
time.sleep(.3)
for number in range(5):
status,body=post('slo-error-'+str(number),fail=True);assert status==503,body
time.sleep(1)
request('http://127.0.0.1:8080/lab/flush')
metrics=wait('metric counter',lambda:(lambda r:r if r['data']['result'] and float(r['data']['result'][0]['value'][1])>=16 else None)(query('sum(lab_requests_total{service_name="book-gateway"})')))
save('metrics-requests.json',metrics)
histogram=query('histogram_quantile(0.95, sum by (le) (rate(lab_http_duration_seconds_bucket{service_name="book-gateway"}[1m])))');save('metrics-p95.json',histogram)
assert histogram['data']['result'],histogram
def firing():
code,value=request('http://127.0.0.1:9095/api/v1/alerts')
return value if code==200 and any(a['labels'].get('alertname')=='BookFastBurn' and a['state']=='firing' for a in value['data']['alerts']) else None
alert=wait('Prometheus fast-burn firing',firing);save('alerts.json',alert)
metric_labels=query('lab_requests_total');save('metric-labels.json',metric_labels)
assert all('order_id' not in row['metric'] and 'trace_id' not in row['metric'] for row in metric_labels['data']['result'])
def exemplars():
expression='lab_http_duration_seconds_bucket{service_name="book-gateway"}'
code,value=request('http://127.0.0.1:9095/api/v1/query_exemplars?'+urllib.parse.urlencode({'query':expression,'start':time.time()-600,'end':time.time()}))
return value if code==200 and value.get('data') else None
samples=wait('real metric exemplars',exemplars);save('exemplars.json',samples)
exemplar_ids={sample['labels']['trace_id'] for series in samples['data'] for sample in series['exemplars'] if 'trace_id' in sample['labels']}
assert exemplar_ids, samples
known={e['trace_id'] for e in events()};assert exemplar_ids & known,(exemplar_ids,known)
save('events.json',events());save('results.json',{'checks':results,'passed':True,'metrics_requests':metrics,'alert':alert,'exemplar_trace_ids':sorted(exemplar_ids)})
print('PASS actual Java/Redis/RocketMQ/OTLP/Tempo/Prometheus: parentage, logs, delays, broken-context, histogram, low-cardinality labels and firing alert')
if __name__=='__main__':main()