.NET + OpenTelemetry

What I Learned Building a Tiny OpenTelemetry Demo in .NET

I wanted a small OpenTelemetry sample that I could actually reason about, not another hello-world API with three packages and no story. So I built a tiny coffee-shop API, added telemetry around the checkout flow, and kept the output in the console where I could see exactly what each request produced.

April 19, 2026

Why I built it this way

Most OpenTelemetry samples lose me when they start with package lists and collector diagrams before there is anything interesting to observe. I wanted the opposite: one simple transaction first, then the telemetry around it.

The API has three routes. One gets product data, one runs checkout, and one fails on purpose. That was enough to test the parts I cared about: the incoming request, the work inside the app, one downstream HTTP call, a couple of business metrics, and a failure path that was not just theoretical.

These screenshots came from the actual project and console output. I kept them in the post because they make the article feel closer to a build log than a cleaned-up slide deck.

1. Start with the right package setup

I kept the dependency set small on purpose. There is a hosting package, a console exporter, ASP.NET Core and HttpClient instrumentation, and runtime instrumentation for process-level signals. I could have added a collector right away, but that would have made the first version harder to explain.

Screenshot of the OpenTelemetryDemo.csproj package references.
The project file stays boring in a good way: one exporter and the instrumentations that matter for this tiny API.

2. Wire up logs, traces, and metrics in one place

The main setup happens at the top of Program.cs. I define the service name once and reuse it for logs, traces, and metrics. That sounds minor, but it makes the console output much easier to scan because everything belongs to the same service.

I also stayed with the console exporter longer than I normally would. When I am learning or validating instrumentation, I like seeing the raw output before I send anything to a dashboard. It removes one whole layer of "is the app wrong, or is the UI hiding something?"

Screenshot of Program.cs showing the OpenTelemetry bootstrap code.
This is the bootstrap code: service metadata, OpenTelemetry logging, tracing, metrics, and the console exporter.

3. Give the business logic its own telemetry names

The first version only had framework instrumentation, and it felt too generic. I could see HTTP requests, but I could not quickly tell what the app was doing. That is why I added a dedicated ActivitySource and a small Meter for checkout behavior.

I kept the metric names plain. I wanted to answer simple questions without decoding my own clever naming scheme: how many checkouts happened, how many failed, and what was the order value?

Screenshot of CoffeeShopTelemetry.cs with the custom activity source and meter definitions.
The custom telemetry gives the app its own vocabulary instead of relying only on framework-generated spans.

4. Wrap the checkout flow in a custom span

The checkout processor starts an internal activity named checkout.process. That ended up being the most useful span in the whole sample because it represents the work I actually care about, not just the HTTP request around it.

I attach the SKU and quantity as tags, then open a logging scope so the exported logs carry the same context. I also made invalid input show up clearly. That was intentional, because a demo that only proves the happy path usually gives you a false sense of confidence.

Screenshot of CheckoutProcessor.cs showing the custom checkout activity and validation path.
The custom span, tags, and logging scope make the checkout operation easier to follow in the exported records.

5. Record business metrics where the work actually completes

After the processor finishes the product lookup and a small simulated delay, it records the two business facts I cared about for this demo: quantity sold and order value. This was the part where the sample started to feel useful instead of academic.

I also keep the span open long enough to add the generated order id and total. Without those details, the trace still exists, but it feels thin. With them, I can look at one request and understand what actually happened.

Screenshot of CheckoutProcessor.cs showing the downstream HTTP call, metric recording, and success logging.
The processor makes a downstream HTTP call, records business metrics, and adds the final order details to the span.

6. Keep the demo endpoints obvious

I did not try to make the endpoints clever. The routes are intentionally obvious: one for products, one for checkout, and one for a forced failure. For a sample like this, I would rather have boring routes and readable telemetry than a more realistic API that distracts from the point.

Screenshot of Program.cs showing the demo API endpoints.
The endpoints are straightforward so the telemetry behavior is easy to predict and explain.

7. A successful request is easy to read

When I hit /api/checkout?sku=latte&quantity=2, the API returns a compact receipt-style payload. It is not fancy, but it gives me a real transaction to trace: two lattes, a total, and an order id.

Screenshot of the JSON returned by the checkout endpoint.
The response is simple. The useful part is how this request shows up in the telemetry output.

8. The console exporter tells the whole story surprisingly well

This was the moment I knew the sample was doing what I wanted. One request produced the server span for /api/checkout, the internal checkout.process span, the tags I added myself, and the custom metrics for quantity and order value.

The forced failure route matters too. If I trigger /api/fail, I get a 500 path with recorded error details. I left that in because observability is not very convincing if it only looks good when everything works.

Screenshot of the console exporter output showing logs, spans, and metrics.
One checkout request produces enough trace, log, and metric data to make the wiring feel worthwhile.

Final thought

I like this kind of sample because it makes OpenTelemetry feel less mysterious. It is easier to understand when I can point to a specific method and say, "that line created the span," or "that counter moved because someone bought two lattes."

If I were taking this further, I would switch from the console exporter to OTLP and send the same signals to a collector-backed UI. But I would still start here. Console output is not glamorous, but it is honest, and for a first pass that is exactly what I want.