Lesson 62 of 66 · 18 min read · Updated
Instrumenting .NET Apps on Kubernetes with OpenTelemetry
On this page
In the previous lesson Prometheus scraped a /metrics endpoint and you could see request rates, latency percentiles and an alert. Metrics answer "is something wrong, and how much?". They rarely answer "why?". When the 95th percentile of checkout doubles, you want to open one slow request and see where the time went: in your API, in the pricing service it called, or in the database behind that. And you want the log lines written during that exact request, not a grep through every Pod.
That needs the other two signals: traces and logs, correlated with each other and with the metrics. OpenTelemetry (OTel) is the open standard for producing all three, and the OpenTelemetry Collector is the component that receives them inside the cluster, enriches them with Kubernetes metadata, and sends them wherever you want. In this lesson you instrument two .NET 10 services, run a Collector, follow one request across both services, find its log lines by trace id, and push the metrics into the Prometheus you installed last time over OTLP.
We will cover the following topics:
- The three signals, and how trace context ties them together
- The OpenTelemetry .NET SDK in .NET 10: traces, metrics and logs with a single
UseOtlpExporter() - Configuring the SDK through
OTEL_*environment variables in a Pod spec - The OpenTelemetry Collector: receivers, processors, exporters and pipelines
- Deployment vs DaemonSet collectors
- The k8sattributes processor and the RBAC it needs
- Sending metrics to Prometheus over OTLP, and choosing a backend for traces and logs
Technical Requirements
- The multi-node kind cluster used throughout this series (Docker Desktop's Kubernetes with the kind provisioner, one control-plane and three workers, Kubernetes v1.36), or an equivalent
kind create clustersetup. kubectl, Helm, Docker and the .NET 10 SDK.- kube-prometheus-stack from the previous lesson, installed as release
kpsin namespacemonitoring. If you skipped it, you can still follow everything except Step 8.
Core Concepts
Three Signals, One Context
| Signal | Question it answers | Unit of data |
|---|---|---|
| Metrics | How many, how fast, how full, over time? | Aggregated numbers with labels |
| Traces | What happened during this request, across services? | Spans: named, timed operations with a parent |
| Logs | What did the code say at that moment? | Timestamped records with a message and attributes |
A trace is a tree of spans. The root span is usually the incoming HTTP request. Every outgoing call, database query or interesting piece of work becomes a child span. Every span carries the same trace id.
When service A calls service B, A's HttpClient adds a traceparent header to the request (the W3C Trace Context standard). B's ASP.NET Core reads it and continues the same trace. No code is needed in either service for that.
Logs written while a span is active carry its trace id and span id. That is the link between the signals: from a slow span you jump to the exact log lines of that request, and from an error log line you jump to the full trace.
OpenTelemetry in .NET 10
.NET does not need a separate tracing API. It has had one built in for years:
| OTel concept | .NET API |
|---|---|
| Tracer / span | ActivitySource / Activity |
| Meter / instrument | System.Diagnostics.Metrics.Meter, Counter<T>, Histogram<T> |
| Logger | Microsoft.Extensions.Logging.ILogger |
ASP.NET Core, HttpClient, Kestrel and the runtime already emit activities and metrics. The OpenTelemetry SDK listens to them and exports them. The packages used in this lesson:
| Package | Version | Purpose |
|---|---|---|
OpenTelemetry.Extensions.Hosting | 1.20.1 | AddOpenTelemetry() on the service collection |
OpenTelemetry.Exporter.OpenTelemetryProtocol | 1.20.1 | OTLP exporter and UseOtlpExporter() |
OpenTelemetry.Instrumentation.AspNetCore | 1.20.0 | Server spans and metrics for incoming requests |
OpenTelemetry.Instrumentation.Http | 1.20.0 | Client spans and metrics for HttpClient |
OpenTelemetry.Instrumentation.Runtime | 1.20.0 | GC, thread pool, exceptions |
UseOtlpExporter() registers one OTLP exporter for all three signals. Without it you would call AddOtlpExporter() separately on tracing, metrics and logging.
Configure with Environment Variables, Not Code
The SDK reads a standard set of environment variables. This is what makes it fit Kubernetes so well: the same image runs in every environment and the Pod spec decides where the data goes.
| Variable | Example | Meaning |
|---|---|---|
OTEL_SERVICE_NAME | catalog-api | The service.name resource attribute. The most important one. |
OTEL_EXPORTER_OTLP_ENDPOINT | http://otel-collector.obs-otel:4317 | Where to send OTLP data |
OTEL_EXPORTER_OTLP_PROTOCOL | grpc (default) or http/protobuf | Wire protocol. gRPC uses port 4317, HTTP uses 4318 |
OTEL_RESOURCE_ATTRIBUTES | service.version=2.0,deployment.environment.name=dev | Extra resource attributes, comma-separated |
OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG | parentbased_traceidratio / 0.1 | Keep 10% of new traces |
OTEL_METRIC_EXPORT_INTERVAL | 60000 | Milliseconds between metric exports (default 60 s) |
A resource describes who produced the telemetry: service name, version, environment, and in Kubernetes the Pod, namespace, node and Deployment. Every span, metric point and log record is attached to a resource.
Why a Collector?
Applications could send OTLP straight to a backend. In practice you put an OpenTelemetry Collector in between:
- Applications only know one address. Switching from Jaeger to Tempo, or adding SigNoz next to Prometheus, is a Collector change. No redeploys.
- The Collector batches, retries and limits memory, so a slow backend does not slow your API.
- It adds information the application does not know, such as the Deployment that owns the Pod.
- It can filter, redact, sample and transform in one place.
A Collector is configured with four kinds of building blocks:
| Block | Examples | Role |
|---|---|---|
| Receivers | otlp, prometheus, file_log, kubelet_stats | How data gets in |
| Processors | memory_limiter, k8s_attributes, batch, transform, filter | What happens to it on the way |
| Exporters | otlp_grpc, otlp_http, debug | Where it goes |
| Pipelines | traces, metrics, logs | Which receivers, processors and exporters are wired together for each signal |
A note on names. Recent Collector releases (0.162 is used here) renamed components to snake_case: k8sattributes became k8s_attributes, the otlp exporter became otlp_grpc, and otlphttp became otlp_http. Most blog posts still use the old names. The Helm chart translates the old names for you, but new configuration should use the new ones.
Deployment vs DaemonSet
| Mode | What it looks like | Use it for |
|---|---|---|
| Deployment (gateway) | A few replicas behind a ClusterIP Service | Central processing: tail sampling, routing, export with credentials. Simplest to start with. |
| DaemonSet (agent) | One Collector per node | Reading container log files from the node, kubelet stats, host metrics. Apps send to the Collector on their own node (status.hostIP) and never cross the network for telemetry. |
Large clusters often run both: an agent on every node that forwards to a gateway. This lesson uses a single Deployment, which is enough for application traces, metrics and logs sent over OTLP.
The k8s_attributes Processor
When a Pod sends data to the Collector, the Collector sees the Pod's IP on the connection. The k8s_attributes processor watches the Kubernetes API, finds the Pod with that IP, and adds attributes such as k8s.namespace.name, k8s.pod.name, k8s.deployment.name, k8s.node.name and k8s.container.name to the resource.
To do that it needs to read Pods, Namespaces and ReplicaSets across the cluster. The ReplicaSet is how it gets from a Pod to its Deployment name. So the Collector's ServiceAccount needs a ClusterRole with get, list and watch on those resources. The Helm chart's kubernetesAttributes preset creates the processor configuration and that RBAC for you.
Where Do Traces and Logs Go?
The Collector is not a database. For the hands-on part we use the debug exporter, which prints everything to the Collector's own log, so you can see exactly what arrives without installing anything else. Metrics go to Prometheus, which has a native OTLP receiver since version 3.
For real use, point the Collector at a backend that stores and searches traces and logs:
| Backend | Signals | Notes |
|---|---|---|
| Jaeger | Traces | CNCF project, simple to run, accepts OTLP natively |
| Grafana Tempo + Loki | Traces + logs | Pairs with the Grafana and Prometheus you already have |
| SigNoz | Traces, metrics, logs | Open-source, OTLP-native, one UI for all three |
| Elastic, Datadog, Azure Monitor, and others | All | Commercial or managed, all accept OTLP |
Swapping between them is a change of exporter in the Collector configuration.
Hands-On: Kubernetes Commands
Add the OpenTelemetry Helm repository
Render the Collector configuration before installing
Shows the final config, with preset processors and RBAC included.
List the components compiled into a Collector image
A component you configure must exist in the distribution you run.
Follow what the Collector receives
With the debug exporter, the Collector's log is your first trace viewer.
Check the Collector's permissions
Needed for the k8s_attributes processor.
Print a Pod's OTEL environment
Quickly confirms where a workload sends its telemetry.
Step-by-Step Example
The Scenario
A storefront has two services. catalog-api serves product pages and handles checkout. For both, it asks pricing-api for the current price over HTTP. You want to:
- See every checkout as one trace spanning both services.
- Read the log lines of that exact checkout.
- Know which Pod, Deployment and node every piece of telemetry came from, without writing that into the application.
- Keep using Prometheus and PromQL for metrics.
Step 1 — Instrument pricing-api
Program.cs:
There is no service name, endpoint or version in the code. All of that comes from the environment. WithLogging() adds an OpenTelemetry logger provider to the normal ILogger pipeline, so existing log statements are exported without changes. Structured logging with message templates ({Price}, {ProductId}) matters here: the values arrive as separate attributes you can filter on, not baked into a string.
Step 2 — Instrument catalog-api, with a Custom Span and Metric
Create CatalogApi the same way and also add OpenTelemetry.Instrumentation.Http --version 1.20.0, because this service makes outgoing calls.
AddSource("Storefront.Catalog") is easy to forget. An ActivitySource that is not registered produces no spans: StartActivity simply returns null, which is why the code uses activity?.. The same applies to meters and AddMeter.
Use the same multi-stage Dockerfile as in the previous lesson (with the right .csproj and .dll names) and build both images:
With a plain kind cluster, load them with kind load docker-image afterwards. Docker Desktop's Kubernetes sees them directly.
Step 3 — Write the Collector Values
Save as collector-values.yaml:
Things to notice:
otel/opentelemetry-collector-k8sis the distribution built for Kubernetes. It includesk8s_attributes,kubelet_stats,file_logand the OTLP components, but not every exporter in the contrib project. For example, it has noprometheusexporter, which is one reason we push metrics to Prometheus over OTLP instead of having Prometheus scrape the Collector.- The order of processors matters.
memory_limitergoes first so it can refuse data before anything else allocates memory.batchgoes last so it batches the final result. exporter/namesyntax (debug/detailed,otlp_http/prometheus) creates several instances of the same component type with different settings.
Step 4 — Install the Collector
The chart also created a ClusterRole for the processor:
Step 5 — Deploy the Services with OTEL Environment Variables
Save as apps.yaml:
Two techniques are combined here. $(POD_NAME) is Kubernetes dependent environment variable syntax: a variable can reference another variable defined earlier in the same list, and the downward API fills POD_NAME from the Pod's metadata. The k8s_attributes processor would add the Pod name anyway; setting it in the SDK as well is a safety net if telemetry ever bypasses the Collector. The endpoint uses the Service's cross-namespace DNS name, otel-collector.obs-otel.
Step 6 — Follow One Checkout Across Both Services
Send some traffic:
Now read the Collector's log. Each batch of spans starts with the resource that produced it:
The first six attributes came from the SDK and the environment variables. Everything from service.namespace down was added by k8s_attributes. The application never mentioned its Deployment, ReplicaSet, node or namespace.
Then come the spans. Picking the spans of one trace id from that log gives the whole checkout:
| Service | Span name | Kind | Span ID | Parent ID |
|---|---|---|---|---|
| catalog-api | POST /checkout/{id:int} | Server | 1f8553c8fa7d44b4 | (root) |
| catalog-api | ReserveStock | Internal | 0b205bdefe7d3fd1 | 1f8553c8fa7d44b4 |
| catalog-api | GET | Client | 54d65468a4735ba4 | 0b205bdefe7d3fd1 |
| pricing-api | GET /prices/{id:int} | Server | b7d7244928f5b61b | 54d65468a4735ba4 |
Read the Parent ID column from top to bottom and you have the call tree: the incoming checkout request, your custom ReserveStock span, the outgoing HttpClient call, and pricing-api's handling of that call, in another Pod and possibly on another node. The traceparent header did that without a line of propagation code.
A server span looks like this in detail:
Step 7 — Find the Logs of That Request
Log records arrive through the logs pipeline with the trace id and span id of the span that was active when they were written:
Same trace id as the checkout, and the span id of pricing-api's server span. In a backend such as SigNoz, Grafana (Tempo + Loki) or Jaeger with a log store, this is the "show logs for this span" button. Notice that the body is the message template, and Price and ProductId are separate attributes, so "all checkouts for product 4" becomes a simple filter.
Step 8 — Send Metrics to Prometheus over OTLP
The Collector's metrics pipeline already points at http://kps-kube-prometheus-stack-prometheus.monitoring:9090/api/v1/otlp. Prometheus only accepts OTLP when the receiver is switched on. Save as kps-otlp-values.yaml:
By default Prometheus maps service.namespace/service.name to the job label and service.instance.id to instance. All other resource attributes go into a separate target_info series. promoteResourceAttributes copies the ones you query by most often onto every series, with dots turned into underscores.
After a couple of minutes of traffic (the SDK exports metrics every 60 seconds by default, and rate() needs two points):
The last query is the 95th percentile of catalog-api's outgoing calls to pricing-api, about 81 ms. Same PromQL as in the previous lesson, but no ServiceMonitor was needed: the data was pushed. Both models coexist happily in one Prometheus.
The debug exporter in the metrics pipeline prints one line per batch, a quick way to see that metrics flow:
Step 9 — Point Traces and Logs at a Real Backend
Replace the debug exporter in the traces and logs pipelines with an OTLP exporter to your backend. The applications do not change. For example, for an OTLP endpoint such as SigNoz, Jaeger or Tempo running in the cluster:
For a hosted backend, add the API key with the headers setting of the exporter and keep the key in a Secret mounted as an environment variable, referenced as ${env:BACKEND_API_KEY}.
Step 10 — Clean Up
If you no longer need Prometheus either, remove it as described at the end of the previous lesson, including the monitoring.coreos.com CRDs.
Summary
- Metrics say that something is wrong, traces show where, logs say why. Trace and span ids tie them together.
- .NET already speaks OpenTelemetry through
Activity,MeterandILogger. The OpenTelemetry SDK exports them;UseOtlpExporter()sends all three signals over OTLP. - Keep endpoints, service names and resource attributes out of code. Set
OTEL_SERVICE_NAME,OTEL_EXPORTER_OTLP_ENDPOINTandOTEL_RESOURCE_ATTRIBUTESin the Pod spec. - Register every custom
ActivitySourceandMeter, or they produce nothing. - The OpenTelemetry Collector decouples applications from backends: receivers, processors, exporters and per-signal pipelines. Put
memory_limiterfirst andbatchlast. - Run it as a Deployment to start, add a DaemonSet agent when you need node-level data such as container log files.
- The k8s_attributes processor adds Pod, Deployment, namespace and node to every signal. It needs a ClusterRole to read Pods, Namespaces and ReplicaSets.
- Prometheus 3 can receive OTLP directly; promote the resource attributes you filter on into labels.
In the next lesson you take a closer look at the ASP.NET Core side of running on Kubernetes: health checks that map to probes, graceful shutdown during rolling updates, and configuration from ConfigMaps and Secrets.