English | 简体中文
This Docker Compose sample models a small read-only pricing flow. A separate .NET 10 traffic-generator console app
acts as an external client and calls the Cart Web API. Cart uses the named pricing-queries RequestBatcher pipeline
to combine product lookups before calling the Pricing Web API. Pricing reads seeded products directly from PostgreSQL.
Grafana OTel LGTM receives traces,
metrics, and logs from the two Web APIs.
The sample uses the concise OpenTelemetry service names cart and pricing, so service selectors and trace views stay
focused on the business services. Telemetry already stored with the previous requestbatcher-cart or
requestbatcher-pricing names remains under those names until the backend retention period expires.
The sample is a local observability demonstration, not a benchmark or production deployment. It has two .NET 10
business projects, Cart and Pricing, plus the RequestBatcher.Observability.TrafficGenerator console project. Each
traffic-generator request contains 2–10 products and emits no OpenTelemetry spans, so the Cart ASP.NET Core request span
is the root visible in the trace. Product IDs 1 through 100 are seeded by
PostgreSQL's initialization script.
Run these commands from the repository root:
# Build images, start services in dependency order, and wait for health checks.
docker compose -f samples/RequestBatcher.Observability/compose.yaml up --build -d --wait
# Inspect the running services and published ports.
docker compose -f samples/RequestBatcher.Observability/compose.yaml psThe Compose build uses the repository root as its context. up --build -d --wait builds the application images, starts the stack in dependency order, and waits for health checks. The default loopback endpoints are:
| Service | URL | Purpose |
|---|---|---|
| Cart | http://127.0.0.1:5081 | Quote API and health endpoint |
| Pricing | http://127.0.0.1:5082 | Product price API and health endpoint |
| Grafana | http://127.0.0.1:3000 | Traces, metrics, logs, and dashboard |
| OTLP/gRPC | http://127.0.0.1:4317 |
Optional local OTLP receiver |
| OTLP/HTTP | http://127.0.0.1:4318 |
OTLP receiver used by the APIs |
Override CART_PORT, PRICING_PORT, GRAFANA_PORT, OTLP_GRPC_PORT, or OTLP_HTTP_PORT when a default is in use.
The traffic generator defaults to 32 workers and a 250 ms delay; override TRAFFIC_CONCURRENCY and
TRAFFIC_DELAY_SECONDS when starting the stack.
Docker runs the separate CheckHealth.cs application to probe each API's /health route before starting dependent
services. This checks HTTP availability, not database readiness. Business endpoints live in CartEndpoints.cs and
PricingEndpoints.cs; the Program.cs files compose and start their hosts.
The repository's .NET 10 SDK can also run the health-check file application directly. The traffic generator is a regular project so Rider can build and run it:
dotnet run --project samples/RequestBatcher.Observability/TrafficGenerator/RequestBatcher.Observability.TrafficGenerator.csproj -- --help
dotnet run --project samples/RequestBatcher.Observability/TrafficGenerator/RequestBatcher.Observability.TrafficGenerator.csproj
dotnet run --file samples/RequestBatcher.Observability/CheckHealth.cs -- http://localhost:5081/healthCheck both APIs and submit a manual quote:
curl --fail http://127.0.0.1:5081/health
curl --fail http://127.0.0.1:5082/health
curl --fail --request POST http://127.0.0.1:5081/cart/quote \
--header 'Content-Type: application/json' \
--data '[{"productId":1,"quantity":2},{"productId":2,"quantity":1}]'
curl --fail --request POST http://127.0.0.1:5081/cart/quote/ordinary \
--header 'Content-Type: application/json' \
--data '[{"productId":1,"quantity":2},{"productId":2,"quantity":1}]'The quote response contains one line per requested product and the calculated total. Cart validates 2 to 10 entries, positive
product IDs, and quantities from 1 through 100. Missing products return 404; invalid input returns 400. The original
/cart/quote route uses the pricing-queries delegate pipeline; /cart/quote/ordinary uses the pricing-queries-ordinary
handler-type pipeline with the same Pricing lookup behavior.
Pricing also supports direct inspection and explicit grouped reads:
curl --fail http://127.0.0.1:5082/prices/1
curl --fail 'http://127.0.0.1:5082/prices?ids=1,2,3,64,100'Open the RequestBatcher observability dashboard
and sign in with admin / admin if prompted. The HTTP panels show request rate and duration; the RequestBatcher panels
show submitted requests, batch size, and batch duration. The logs and trace panels show the two services' logs and Tempo
trace families for RequestBatcher.Execute and RequestBatcher.Handle.
The visible trace starts with the Cart ASP.NET Core request span. Each Cart request creates one
RequestBatcher.Execute span named for its pipeline (pricing-queries or pricing-queries-ordinary) for its complete 2–10 ID sequence. The shared Cart batch handler receives up to 20
product requests, mixes requests from concurrent Cart calls, deduplicates their IDs, and makes one HTTP call to Pricing for the group. Pricing's ASP.NET Core span contains the
Npgsql database span created by Npgsql's official OpenTelemetry integration. The Cart batch spans are independent
Consumer roots. Each Handle span links to the contributing caller Execute contexts and carries
requestbatcher.name=pricing-queries. A single Cart trace
has one Execute span, but an explicit 2–10 item submission can be split across multiple Handle spans; a batch can
also mix product requests from concurrent Cart traces. requestbatcher.batch.size records product requests in that batch.
To follow one call into a batch, open the Cart trace, copy the Execute span's trace ID, and search Tempo with:
{ link:traceID = "CALL_TRACE_ID" && span:name =~ "RequestBatcher[.]Handle( .*)?" }
The pinned Tempo build stores this query field without leading zeroes. Remove leading 0 characters from a copied
trace ID before substituting it above. Select a time range that includes the original call. A batch can contain fewer than
20 submissions because RequestBatcher does not wait to fill BatchSize.
Open the returned batch span to inspect the Cart-to-Pricing HTTP child and the Pricing database span. Activity links are
causal references rather than shared parent edges, so they do not imply one shared trace tree. Each Handle span
links to every contributing Execute span, and each Execute span links to every Handle span that handled
its requests. See Grafana's TraceQL link query documentation
for the link:traceID and link:spanID selectors.
The following example uses the default /cart/quote endpoint.
For direct navigation, open a Handle trace from the dashboard's Handler execution · Handle table, click the
RequestBatcher.Handle pricing-queries span row, and expand References in the span details to see its Execute
callers. Click an Execute span in the Cart calls · Execute table and expand References to see the
Handle spans that processed it. Grafana groups parent references and span links in this list: the cart POST /cart/quote
entry is Execute's HTTP parent in the same trace, while handler links target independent Handle traces.
The parent entry is not an extra ActivityLink. To find the Execute spans from
a batch, copy its trace and span IDs and query Tempo with:
{ link:traceID = "BATCH_TRACE_ID" && link:spanID = "BATCH_SPAN_ID" && span:name = "RequestBatcher.Execute pricing-queries" }
The pinned Tempo build stores this query field without leading zeroes; remove leading 0 characters from copied IDs
before substituting them above. Select a time range that includes the original batch. The TraceQL query above is useful
when the backend does not expose a clickable link in the span details.
The sample design documents the current flow and compatibility contract. The sample source shows the two Web APIs, the traffic generator, and their telemetry setup.
For end-to-end validation, see the integration test guide
(中文). Running the full integration-test project requires Docker; its Testcontainers
fixture creates an isolated environment and cleans it up after validation. The live test checks one Cart Execute per
HTTP request, cross-trace batch links, Pricing/database parent-child spans, and metrics. To run the in-process tests
without Docker, use the TracingStressTests and MetricsAdviceTests filter shown in the guide.
RequestBatcher is in-process and does not recover pending requests after a process failure.