<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ OpenTelemetry - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ OpenTelemetry - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sun, 18 Oct 2026 09:33:03 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/opentelemetry/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ Claude Code Observability with OpenTelemetry ]]>
                </title>
                <description>
                    <![CDATA[ Agentic coding tools like Claude Code, OpenAI Codex, Google Antigravity, and Cursor have become ubiquitous for everyday software development. As agentic systems mature, much of the work developers hav ]]>
                </description>
                <link>https://www.freecodecamp.org/news/claude-code-observability-with-opentelemetry/</link>
                <guid isPermaLink="false">6a9a0db7c7c0575bd6526dd2</guid>
                
                    <category>
                        <![CDATA[ OpenTelemetry ]]>
                    </category>
                
                    <category>
                        <![CDATA[ claude ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #prometheus ]]>
                    </category>
                
                    <category>
                        <![CDATA[ distributed tracing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ observability ]]>
                    </category>
                
                    <category>
                        <![CDATA[ generative ai ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Puneet Singh ]]>
                </dc:creator>
                <pubDate>Fri, 04 Sep 2026 00:15:51 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/2a729ee5-e1b9-4198-91cd-251b9c12867f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Agentic coding tools like <a href="https://claude.com/claude-code">Claude Code</a>, <a href="https://openai.com/codex">OpenAI Codex</a>, <a href="https://antigravity.google">Google Antigravity</a>, and <a href="https://cursor.com">Cursor</a> have become ubiquitous for everyday software development.</p>
<p>As agentic systems mature, much of the work developers have them do is delegated, one subagent at a time. Many teams are also exploring and using a shared, multi-tenant Agentic Infrastructure, where cost isn't tied to a single owner. That's where Observability becomes key to monitoring infrastructure costs.</p>
<p>In this guide, you'll learn how observability works, then enable Claude Code's built-in telemetry, run a backend to collect it, and read the metrics, logs, and traces it emits. This will help you start tracking your team's costs more effectively, and it'll only improve as emitted telemetry matures and correlates more cleanly with your sessions.</p>
<p><strong>Note</strong>: In its current state, the emitted telemetry from Claude Code provides no attributes that allow a reliable map to named sessions. Usage can be tracked using session_id, but it's still clumsy in a longer session mixing multiple prompts/skills.</p>
<p>This guide is scoped to <code>Claude Code</code>'s telemetry for metrics, logs, and tracing. Note that it applies to Linux and macOS only.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-observability-with-opentelemetry">Observability with OpenTelemetry</a></p>
<ul>
<li><a href="#heading-telemetry-data">Telemetry Data</a></li>
</ul>
</li>
<li><p><a href="#heading-instrumenting-claude-code">Instrumenting Claude Code</a></p>
<ul>
<li><p><a href="#heading-pull-vs-push-how-telemetry-leaves-an-app">Pull vs Push: How Telemetry Leaves an App</a></p>
</li>
<li><p><a href="#heading-when-to-run-a-collector">When to run a Collector</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-setup">Setup</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-exploring-telemetry">Exploring Telemetry</a></p>
<ul>
<li><p><a href="#heading-metrics">Metrics</a></p>
</li>
<li><p><a href="#heading-logs">Logs</a></p>
</li>
<li><p><a href="#heading-tracing">Tracing</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-observability-with-opentelemetry">Observability with OpenTelemetry</h2>
<p>Observability is the ability to answer questions about a system's runtime behavior from the data it emits. You do this without looking into its internals, attaching a debugger, reading source code, or manually trying to reproduce the behavior.</p>
<p>Here, a system's runtime behavior means what's externally visible. You can ask questions like:</p>
<ul>
<li><p>How much time 95% of all requests take.</p>
</li>
<li><p>What the failure rate is across all requests received.</p>
</li>
<li><p>What the cache hit ratio is for the in-memory cache the service uses.</p>
</li>
<li><p>The difference between the configured and deployed replica counts for a service.</p>
</li>
</ul>
<p>For Claude Code, the inaccessible inner workings are: how it manages context, how work is divided across multiple LLM calls, and how subagents are orchestrated. But you can read the emitted telemetry from Claude code to answer questions like:</p>
<ul>
<li><p>How much a dev or a team spent over a day, week, or month.</p>
</li>
<li><p>How that usage is distributed across the supported models and effort levels.</p>
</li>
<li><p>How many tokens are spent per dollar, and how much that varies by type (input, output, cacheRead, cacheCreation).</p>
</li>
<li><p>When a compaction event kicked in, and by how much it reduced the context's token usage.</p>
</li>
</ul>
<p>Only an instrumented system can answer these questions. Instrumentation is a piece of code added by the developer or built into the tool that records a program's runtime behavior and emits it as telemetry. For example, a measurement like <code>this request spent 100 tokens</code>.</p>
<p>The telemetry data helps avoid silent failures by providing a well-structured data trail of the system's behavior over time. For example, here's a chart from <a href="https://github.blog/news-insights/company-news/the-august-17-outage-and-the-work-ahead/">GitHub's August 17, 2026 outage</a> postmortem explaining a rise in GitHub Actions runs over time from ~30M to ~110M:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/fe9ec420-2137-4a21-93aa-00255638bc32.png" alt="Github Actions Growth" style="display: block;" width="1536" height="864" loading="lazy">

<h3 id="heading-telemetry-data">Telemetry Data</h3>
<p>The emitted telemetry consists of three categories of data:</p>
<ul>
<li><p><a href="https://opentelemetry.io/docs/concepts/signals/metrics/"><strong>Metrics</strong></a>: Numeric measurements aggregated over a time window, like queries per second (QPS).</p>
</li>
<li><p><a href="https://opentelemetry.io/docs/concepts/signals/logs/"><strong>Logs</strong></a>: A detailed record of an individual event, with a timestamp. For example, a compaction event in Claude Code.</p>
</li>
<li><p><a href="https://opentelemetry.io/docs/concepts/signals/traces/"><strong>Traces</strong></a>: The path of one request through the system, split by time into nested requests. For example, an order-placement request on an ecommerce website, showing which internal services it calls to complete the request.</p>
</li>
</ul>
<p>OpenTelemetry (<a href="https://opentelemetry.io/">https://opentelemetry.io/</a>) is an observability framework that helps you generate, collect, and export these signals to a backend, which handles storage, querying, and visualization. Keeping that split makes it tool/vendor agnostic where the backend can be open-source or proprietary. It provides instrumentation SDKs for multiple <a href="https://opentelemetry.io/docs/languages/">programming languages</a>.</p>
<h2 id="heading-instrumenting-claude-code">Instrumenting Claude Code</h2>
<p>Instrumentation code usually runs alongside the application, at the points best for measuring: a middleware with a request arriving or a response going out, or a token being counted.</p>
<p>It is plugged into the application in two ways:</p>
<ol>
<li><p>A Shared Instrumentation Library: Applications using an open source framework can add an instrumentation library as a dependency and link it with the application's lifecycle methods. OpenTelemetry publishes instrumentation libraries for many frameworks (Example: <a href="https://opentelemetry.io/docs/zero-code/java/spring-boot-starter/out-of-the-box-instrumentation/">Spring Framework</a>).</p>
</li>
<li><p>Customized Implementation by Application Developers: Telemetry data emitters are added directly in the codebase using the OpenTelemetry SDK. For a closed-source product, the code is private, but it can still emit telemetry data compatible with OpenTelemetry standards.</p>
</li>
</ol>
<h3 id="heading-example-http-instrumentation">Example: HTTP Instrumentation</h3>
<p>Middleware in HTTP handling is a common codepath for all requests, which is why it's chosen for application-wide settings like authentication. The same reason makes it a good place for instrumentation code: wrap the handler once, measure every request.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/e0699b5b-5459-4a28-befb-87dce792f4e9.png" alt="HTTP Instrumentation example" style="display: block;" width="3158" height="1490" loading="lazy">

<p>The diagram shows where instrumentation sits relative to the request path.</p>
<ul>
<li><p>A client request passes through an HTTP middleware before reaching the application's request handler and downstream calls. The middleware uses SDK constructs to time the handler and record duration, status, and a count.</p>
</li>
<li><p>The SDK then buffers those measurements and pushes OTLP to the Collector on a background thread, off the request path.</p>
</li>
</ul>
<p>Claude Code is the second case, where both the core app and its instrumentation module are provided by <a href="https://code.claude.com/docs/en/monitoring-usage">Anthropic</a>. The code that measures token usage, cost, and tool calls is built in and emits OpenTelemetry over <a href="https://opentelemetry.io/docs/specs/otlp/">OTLP</a>. As a Claude Code user, you only need to enable the telemetry and prepare a backend to receive and analyze it.</p>
<p><strong>Note</strong>: <a href="https://opentelemetry.io/docs/specs/otlp/">OTLP</a> is a telemetry data delivery protocol designed in the scope of the OpenTelemetry project. This guide assumes end-to-end compatibility with OTLP. Wiring up incompatible telemetry or using incompatible backend components can give unpredictable results and is out of scope.</p>
<p>To collect, store, and read the telemetry, you'll need the following components:</p>
<ul>
<li><p><a href="https://opentelemetry.io/docs/collector/">OpenTelemetry Collector</a>: A vendor-agnostic implementation of how to receive, process, and export telemetry data. This is optional but great to have for personal setup. Must have for a production use case.</p>
</li>
<li><p><a href="https://www.jaegertracing.io/">Jaeger</a>: Distributed tracing backend, released as an open source tool by Uber.</p>
</li>
<li><p><a href="https://prometheus.io/">Prometheus</a>: Collects and stores metrics as Timeseries Data.</p>
</li>
<li><p><a href="https://grafana.com/oss/loki/">Loki</a>: Scalable Log Aggregation system by Grafana.</p>
</li>
<li><p><a href="https://grafana.com/oss/grafana/">Grafana</a>: for UI visualization of logs, metrics, and traces.</p>
</li>
</ul>
<h3 id="heading-pull-vs-push-how-telemetry-leaves-an-app">Pull vs Push: How Telemetry Leaves an App</h3>
<p>Telemetry leaves an application in one of two ways:</p>
<p><strong>Pull (Scrape)</strong>: The app exposes its current metrics on an HTTP endpoint, and a scraper (Prometheus) reads that endpoint periodically. Every running instance needs its own port, and the scraper must know all of those addresses ahead of time. The app is passive: the scraper drives the data movement. This suits long-lived processes with stable addresses.</p>
<p>In OpenTelemetry, pull-based scraping is configured with <code>OTEL_METRICS_EXPORTER=prometheus</code> (see the <a href="https://opentelemetry.io/docs/languages/sdk-configuration/general/">SDK environment variables</a> for the accepted exporter values).</p>
<p>By convention, the app makes its metrics available at <code>http://localhost:9464/metrics</code>. This exporter handles metrics only.</p>
<p><strong>Push (OTLP)</strong>: The app sends its telemetry to a receiving endpoint on a regular interval. This is more flexible: any number of processes can push to the same endpoint with no registration ahead of time, so apps can start and stop freely even as their addresses change.</p>
<p>In OpenTelemetry, push is configured with <code>OTEL_METRICS_EXPORTER=otlp</code>, which ships over the <a href="https://opentelemetry.io/docs/specs/otel/protocol/exporter/">OTLP exporter</a>.</p>
<p>The push model can carry metrics, logs, and traces.</p>
<h3 id="heading-when-to-run-a-collector">When to Run a Collector</h3>
<p>A Collector is deployed when the existing stack isn't enough to handle the system's growing scale and complexity. It helps in the following ways:</p>
<ul>
<li><p><strong>One export config</strong>: every producer points at the Collector instead of each carrying its own per-backend exporter setup.</p>
</li>
<li><p><strong>Outbound-only connections</strong>: enterprise networks often block the inbound connections a pull-based scraper needs. With a Collector, the app pushes out to it and it pushes onward, so nothing has to accept inbound traffic.</p>
</li>
<li><p><strong>Fan-out and translation</strong>: the Collector can convert telemetry into a vendor's storage format and send the same signal to more than one backend.</p>
</li>
<li><p><strong>Buffering</strong>: if a backend goes down, the Collector holds the data and retries, absorbing transient failures.</p>
</li>
<li><p><strong>Processing</strong>: it can apply <a href="https://opentelemetry.io/docs/collector/configuration/#processors">processors</a> before data leaves for storage, such as redacting attributes.</p>
</li>
</ul>
<p><strong>Note</strong>: This guide runs a push-based configuration with a Collector even though the setup is single-user. The stack has three backends that store and query data differently, and letting the Collector receive Claude Code's OTLP once and route each signal to the right place is simpler than wiring the app to all three. That's why the tool list calls it optional for personal use but a must-have in production: its value grows with the number of producers and backends.</p>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>Each section in this guide links relevant docs, but you'll make faster progress if the tools and query languages specified below are already familiar:</p>
<p><strong>You'll need</strong></p>
<ul>
<li><p>Latest version of <a href="https://code.claude.com/docs/en/setup">Claude Code</a></p>
</li>
<li><p><a href="https://docs.docker.com/engine/install/">Docker Engine</a> (29.4.1+) with <a href="https://docs.docker.com/compose/">Docker Compose</a>.</p>
<ul>
<li>Capacity to run 5 Containers (4-core CPU, 8GB RAM, 15GB Disk)</li>
</ul>
</li>
<li><p>A Claude Plan that includes <a href="https://code.claude.com/docs/en/monitoring-usage#traces-beta">Enhanced Telemetry beta</a> (Pro+ / Max)</p>
</li>
<li><p><a href="https://git-scm.com/">Git</a></p>
</li>
<li><p>Ensure these ports are free at localhost:</p>
<ul>
<li><p><code>3000</code> (Grafana)</p>
</li>
<li><p><code>3100</code> (Loki)</p>
</li>
<li><p><code>4317</code>/<code>4318</code> (OTel Collector OTLP gRPC/HTTP)</p>
</li>
<li><p><code>9090</code> (Prometheus)</p>
</li>
<li><p><code>16686</code> (Jaeger UI)</p>
</li>
</ul>
</li>
</ul>
<p><strong>Knowledge that would help</strong></p>
<ul>
<li><p><a href="https://docs.docker.com/reference/cli/docker/compose/">Docker Compose</a>: bringing up containers defined in compose file, reading container status and logs by <code>docker compose ...</code> commands.</p>
</li>
<li><p><a href="https://www.gnu.org/software/bash/manual/">Bash</a> and Config files: Setting environment variables, editing <a href="https://www.json.org/json-en.html">JSON</a> files.</p>
</li>
<li><p><a href="https://prometheus.io/docs/prometheus/latest/querying/basics/">PromQL</a> (Prometheus): How to use counters/gauges, range selectors, and <code>sum</code> / <code>increase</code> / <code>rate</code> / <code>by</code> (label) grouping.</p>
</li>
<li><p>Grafana: <a href="https://grafana.com/docs/grafana/latest/explore/">Explore</a> a datasource, build <a href="https://grafana.com/docs/grafana/latest/panels-visualizations/">dashboards</a> with stat and timeseries panels. Panel <a href="https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/transform-data/">transformations</a> and <a href="https://grafana.com/docs/grafana/latest/visualizations/dashboards/variables/global-variables/">Global variables</a>.</p>
</li>
<li><p><a href="https://grafana.com/docs/loki/latest/query/">LogQL</a> (Loki): stream selectors, logfmt, and label_format.</p>
</li>
<li><p>Jaeger and tracing: the <a href="https://opentelemetry.io/docs/concepts/signals/traces/">trace/span model</a> (parent-child spans, span count, duration) and the <a href="https://www.jaegertracing.io/docs/latest/frontend-ui/">Jaeger UI's tag search</a>.</p>
</li>
<li><p>Claude Code's execution model: sessions, <a href="https://code.claude.com/docs/en/sub-agents">subagents</a>, skills, tools, and context compaction.</p>
</li>
<li><p><a href="https://code.claude.com/docs/en/costs">Claude billing basics</a>: tokens and the <a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching">prompt-caching tiers</a>.</p>
</li>
</ul>
<h3 id="heading-setup">Setup</h3>
<p>The test observability stack is deployed using Docker Compose. For telemetry export to work, Claude Code must be able to reach Collector's OTLP endpoint which is localhost:4317 (gRPC) or localhost:4318(HTTP) when running on same machine. All backend services run in a container with their own Docker volume for persistence.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/d486a15e-1b71-490a-8f5f-9489ffe0edab.png" alt="Instrumentation Setup" style="display: block;" width="1894" height="1946" loading="lazy">

<p>The diagram shows how the telemetry components we're using are linked.</p>
<ul>
<li><p>Apart from Claude Code, every component runs in a container managed by Docker Compose.</p>
</li>
<li><p>Multiple instances of Claude Code running on any machine (host or cloud VM) should be able to export telemetry to the Collector as long as those machines have connectivity to it (ports 4317/4318 of the host running the Collector container are reachable).</p>
</li>
</ul>
<p>Moving from top to bottom:</p>
<ul>
<li><p>Claude Code exports all three signals over OTLP to the Collector.</p>
</li>
<li><p>The Collector then splits them by type, pushing traces to Jaeger and logs to Loki, while exposing metrics on port 8889 for Prometheus to scrape.</p>
</li>
<li><p>Jaeger, Prometheus, and Loki each persist to their own Docker volume.</p>
</li>
<li><p>Grafana queries all three as the single dashboard layer.</p>
</li>
</ul>
<p>The goal here is a live stream of telemetry from Claude Code that gets stored in a backend and can be queried on demand, during a session or long after. Two things must be in place:</p>
<ul>
<li><p>Enable telemetry in Claude Code. The instrumentation is built in but emits nothing until telemetry is enabled and its OTLP exporter points at the Collector.</p>
</li>
<li><p>Run the observability backend. The Collector processes each signal, forwarding it to Prometheus, Loki, and Jaeger for storing and serving queries.</p>
</li>
</ul>
<p>You'll start the backend first, so the telemetry has somewhere to go.</p>
<h4 id="heading-start-the-observability-backend">Start the Observability Backend</h4>
<p>Before enabling telemetry in Claude Code, ensure the stack is up to collect, process, and read the data. The code for the test observability backend lives in this <a href="https://github.com/ps-mir/otel-dev-stack">Github repo</a>.</p>
<p>The repo has the following structure:</p>
<pre><code class="language-bash">.
├── README.md
└── compose
    ├── docker-compose.yml # Docker config for 5 containers in the observability stack. Applies pinned image versions, port mappings and named volumes for each service.
    ├── grafana
    │&nbsp;&nbsp; └── provisioning
    │&nbsp;&nbsp;     ├── alerting
    │&nbsp;&nbsp;     ├── dashboards
    │&nbsp;&nbsp;     ├── datasources # datasources(Prometheus, Loki, Jaeger) and dashboards. Empty initially.
    │&nbsp;&nbsp;     └── plugins
    ├── jaeger-config.yaml # Jaeger v2, badger (local-file) storage for traces. Ties to the user: root TIP below.
    ├── loki-config.yaml # single-binary Loki, filesystem storage. Near default settings.
    ├── otel-collector-config.yaml # receive/process/export pipeline: OTLP in on 4317/4318, traces out to Jaeger, logs to Loki, metrics exposed on :8889 for Prometheus.
    └── prometheus.yml # a single scrape job against the Collector's :8889, 30s interval.
</code></pre>
<p>You'll only need <code>docker compose</code> command to start the containers. It reads <a href="https://github.com/ps-mir/otel-dev-stack/blob/690d485fb89491fcd940b550377d9a2cc2dcc084/compose/docker-compose.yml">docker-compose.yml</a> and starts the containers, linking them to their respective config files.</p>
<h4 id="heading-connectivity-between-containers">Connectivity between containers:</h4>
<p>All containers start within the same Docker network, which allows them to communicate using container names directly. For example, <a href="https://github.com/ps-mir/otel-dev-stack/blob/690d485fb89491fcd940b550377d9a2cc2dcc084/compose/otel-collector-config.yaml">collector's exporter config</a> uses container names:</p>
<pre><code class="language-yaml">exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true
  prometheus:
    endpoint: 0.0.0.0:8889
  otlphttp/loki:
    endpoint: http://loki:3100/otlp
</code></pre>
<p>Note that it doesn't contain Prometheus config, since Prometheus ends up scraping it from the collector as configured in <a href="https://github.com/ps-mir/otel-dev-stack/blob/690d485fb89491fcd940b550377d9a2cc2dcc084/compose/prometheus.yml">prometheus.yml</a>:</p>
<pre><code class="language-yaml">global:
  scrape_interval: 30s

scrape_configs:
  - job_name: otel-collector
    static_configs:
      - targets: ["otel-collector:8889"]
</code></pre>
<p>Start the containers using Docker compose:</p>
<pre><code class="language-bash">git clone https://github.com/ps-mir/otel-dev-stack.git
cd otel-dev-stack/compose
docker compose up -d

# Output
 ✔ Volume compose_loki_data                          Created                                                                                                                                          0.0s
 ✔ Volume compose_grafana_data                       Created                                                                                                                                          0.0s
 ✔ Volume compose_prometheus_data                    Created                                                                                                                                          0.0s
 ✔ Volume compose_jaeger_data                        Created                                                                                                                                          0.0s
 ✔ Network compose_default                           Created                                                                                                                                          0.1s
 ✔ Container compose-prometheus-1                    Started                                                                                                                                          4.1s
 ✔ Container compose-loki-1                          Started                                                                                                                                          4.2s
 ✔ Container compose-jaeger-1                        Started                                                                                                                                          4.3s
 ✔ Container compose-otel-collector-1                Started                                                                                                                                          3.3s
 ✔ Container compose-grafana-1                       Started                                                                                                                                          2.7s
</code></pre>
<p>Check container status:</p>
<pre><code class="language-bash"># all five services should show "Up"
docker compose ps

# Output
NAME                       IMAGE                                              COMMAND                  SERVICE          CREATED         STATUS         PORTS
compose-grafana-1          grafana/grafana:13.2.0                            "/run.sh"                grafana          3 minutes ago   Up 3 minutes   0.0.0.0:3000-&gt;3000/tcp, [::]:3000-&gt;3000/tcp
compose-jaeger-1           cr.jaegertracing.io/jaegertracing/jaeger:2.20.0   "/go/bin/jaeger --co…"   jaeger           3 minutes ago   Up 3 minutes   0.0.0.0:16686-&gt;16686/tcp, [::]:16686-&gt;16686/tcp
compose-loki-1             grafana/loki:3.7.6                                "/usr/bin/loki -conf…"   loki             3 minutes ago   Up 3 minutes   0.0.0.0:3100-&gt;3100/tcp, [::]:3100-&gt;3100/tcp
compose-otel-collector-1   otel/opentelemetry-collector-contrib:0.159.0      "/otelcol-contrib --…"   otel-collector   3 minutes ago   Up 3 minutes   0.0.0.0:4317-4318-&gt;4317-4318/tcp, [::]:4317-4318-&gt;4317-4318/tcp, 55679/tcp
compose-prometheus-1       prom/prometheus:v3.11.2                           "/bin/prometheus --c…"   prometheus       3 minutes ago   Up 3 minutes   0.0.0.0:9090-&gt;9090/tcp, [::]:9090-&gt;9090/tcp
</code></pre>
<p><strong>TIP</strong>: Jaeger runs as <code>user: root</code> (Compose file) to create the badger dir. Not doing so causes a failure: <code>mkdir /badger/key: permission denied</code>. Jaeger itself doesn't need root permission, but Docker volumes are owned by <code>root:root</code> on first mount.</p>
<h4 id="heading-enable-telemetry">Enable Telemetry</h4>
<p>OpenTelemetry instrumentation, once added to an application, stays disabled until specific configuration enables it.</p>
<p>There are two ways to enable telemetry in Claude Code:</p>
<p>1. Environment Variables</p>
<p>Setting specific environment variables enables telemetry generation. Beyond the standard <code>OTEL_*</code> variables, Claude Code defines its own <code>CLAUDE_CODE_*</code> variables.</p>
<p>This guide uses the following settings:</p>
<pre><code class="language-bash"># master switch: when unset or 0, Claude Code produces no telemetry at all
export CLAUDE_CODE_ENABLE_TELEMETRY=1

# opt into the beta enhanced-telemetry attributes and events (extra session and tool detail)
export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1

# per-signal exporter selection; "otlp" ships the signal over OTLP.
# other accepted values are "console" (print locally), "prometheus" (metrics only), and "none" (drop the signal)
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_TRACES_EXPORTER=otlp

# OTLP transport: "grpc" talks to the collector's 4317 port; "http/protobuf" would use 4318
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc

# one endpoint for all three signals: the collector's OTLP listener on the local machine
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317

# how often metrics are flushed, in milliseconds; the default is 60000 (60s),
# shortened here so a manual check sees fresh data without a long wait
export OTEL_METRIC_EXPORT_INTERVAL=5000

# emit cumulative counters instead of delta (see "Aggregation Temporality" below)
export OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=cumulative
</code></pre>
<p>Environment variables, however, are process-wide and can affect more than Claude Code. For example,</p>
<ul>
<li><p>Accidentally enabling instrumentation in other applications.</p>
</li>
<li><p>Interfering with OpenTelemetry code/tests if you're developing your own instrumentation or working on any OpenTelemetry SDK.</p>
</li>
</ul>
<p>2. Claude Code <code>settings.json</code></p>
<p>OpenTelemetry defines <a href="https://opentelemetry.io/docs/languages/sdk-configuration/declarative-configuration/">Declarative Config</a>, a YAML based configuration to enable telemetry, but Claude Code doesn't support it. But it lets you set the same env variables in <code>~/.claude/settings.json</code>. That isn't declarative config, but it's better than shell environment variables because it applies only to Claude Code. An example:</p>
<pre><code class="language-json">{
  "effortLevel": "medium",
  "tui": "fullscreen",
  "env": {
     "CLAUDE_CODE_ENABLE_TELEMETRY": "1",
     "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA": "1",
     "OTEL_METRICS_EXPORTER": "otlp",
     "OTEL_LOGS_EXPORTER": "otlp",
     "OTEL_TRACES_EXPORTER": "otlp",
     "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
     "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317",
     "OTEL_METRIC_EXPORT_INTERVAL": "5000",
     "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE": "cumulative"
  }
}
</code></pre>
<p>Only the <code>env</code> block matters for telemetry. <code>effortLevel</code> and <code>tui</code> are unrelated settings you may already have. The variables match the annotated list above.</p>
<h4 id="heading-aggregation-temporality">Aggregation Temporality</h4>
<p>Prometheus <a href="https://prometheus.io/docs/concepts/metric_types/#counter">counter</a> type metrics only increase over time. Their raw value isn't useful, so you read them through per-second growth (<code>rate()</code>) or total growth over a time window (<code>increase()</code>).</p>
<p>Aggregation temporality decides what number a counter reports on each telemetry export: the change since the previous export(Delta), or the running total since the process started(Cumulative).</p>
<p>A short example. Say Claude Code spends tokens over four 5-second export intervals:</p>
<table>
<thead>
<tr>
<th>Export at</th>
<th>Tokens since last export</th>
<th>Delta value sent</th>
<th>Cumulative value sent</th>
</tr>
</thead>
<tbody><tr>
<td>0s (start)</td>
<td>--</td>
<td>--</td>
<td>0</td>
</tr>
<tr>
<td>5s</td>
<td>100</td>
<td>100</td>
<td>100</td>
</tr>
<tr>
<td>10s</td>
<td>0</td>
<td>0</td>
<td>100</td>
</tr>
<tr>
<td>15s</td>
<td>250</td>
<td>250</td>
<td>350</td>
</tr>
<tr>
<td>20s</td>
<td>50</td>
<td>50</td>
<td>400</td>
</tr>
</tbody></table>
<p>By default, Claude Code emits metrics with <code>AggregationTemporality: Delta</code>. This can be inspected and confirmed from the Collector's container logs using the command:</p>
<pre><code class="language-bash"># Command only works from directory containing docker-compose.yml
docker compose logs otel-collector
</code></pre>
<p><strong>Note</strong>: To enable detailed logs in the Collector, you need to add the <code>debug</code> exporter to the <a href="https://github.com/ps-mir/otel-dev-stack/blob/690d485fb89491fcd940b550377d9a2cc2dcc084/compose/otel-collector-config.yaml">Collector config</a>.</p>
<pre><code class="language-yaml">service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlp/jaeger, debug]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [prometheus, debug]
</code></pre>
<p>Then restart the container:</p>
<pre><code class="language-bash"># Command only works from directory containing docker-compose.yml
docker compose up -d --force-recreate otel-collector
</code></pre>
<p>Log output with <code>AggregationTemporality: Delta</code>:</p>
<pre><code class="language-bash">otel-collector-1  | Descriptor:
otel-collector-1  |      -&gt; Name: claude_code.active_time.total
otel-collector-1  |      -&gt; Description: Total active time in seconds
otel-collector-1  |      -&gt; Unit: s
otel-collector-1  |      -&gt; DataType: Sum
otel-collector-1  |      -&gt; IsMonotonic: true
otel-collector-1  |      -&gt; AggregationTemporality: Delta &lt;---
otel-collector-1  | NumberDataPoints #0
</code></pre>
<p>Delta doesn't work well with Prometheus functions like <code>rate()</code>/<code>increase()</code>, since they expect cumulative values.</p>
<p>Setting <code>OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=cumulative</code> switches the exported metrics from delta to cumulative temporality. It has been added to both the env and JSON config used in this guide.</p>
<p>As before, you need to restart the Collector container after any config change for it to take effect.</p>
<h2 id="heading-exploring-telemetry">Exploring Telemetry</h2>
<p>With telemetry flowing, you can start querying it. Metrics, logs, and traces each answer a different kind of question about Claude Code usage, so the three sections below are largely independent.</p>
<p>The data behind them comes from two places.</p>
<ul>
<li><p>The Metrics and Logs sections query whatever Claude Code usage has accumulated in the backend, so your panels will show your own sessions and the numbers won't match the screenshots. Give it a few real sessions before expecting much to show.</p>
</li>
<li><p>The Tracing section instead walks a single deliberate run, a custom skill summarizing a batch of meetings, described in enough detail to follow along. You don't need to reproduce it.</p>
</li>
</ul>
<h3 id="heading-metrics">Metrics</h3>
<p>Metrics are the aggregate, time-windowed view of Claude Code usage. Example: total cost, token volume, and how each trends and breaks down by attributes like <code>model</code>, <code>effort</code>, and token <code>type</code>. Use them to watch spend and spot shifts in consumption.</p>
<p>Each metric is a numeric measurement recorded over time, a <a href="https://prometheus.io/docs/concepts/data_model/">time series</a> of timestamped values you can plot or aggregate. Claude Code's metrics are running totals (counters), so a query reports the change over a chosen window rather than the raw value. See <a href="#heading-aggregation-temporality">Aggregation Temporality</a> above for how that works.</p>
<p><a href="https://prometheus.io/docs/introduction/overview/">Prometheus</a> is the metrics backend we're using here. It scrapes the Collector, stores the series, and answers queries written in <a href="https://prometheus.io/docs/prometheus/latest/querying/basics/">PromQL</a>. Grafana reads the same data for dashboards at <code>localhost:3000</code>. The full list of metrics and their attributes is in the <a href="https://code.claude.com/docs/en/monitoring-usage">Claude Code monitoring docs</a>.</p>
<p>Open Prometheus in your browser (localhost:9090), type <code>claude</code> in the query field, and you should see the supported metrics:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/8a4703dc-8b10-47b5-95ca-2cc383ab052a.png" alt="Available Claude Code metrics of counter type in Prometheus." style="display: block;" width="1690" height="720" loading="lazy">

<p>For each metric below, you'll explore it first using PromQL, and then use the same query to add it to the Grafana dashboard as a panel.</p>
<h4 id="heading-total-usd-spent">Total USD Spent</h4>
<p><code>claude_code_cost_usage_USD_total</code> represents cumulative usage cost, in USD, tracked per session. It's useful for controlling budgets and spotting sudden spikes in usage.</p>
<p>This is a client-side estimate based on token counts priced at Anthropic's per-model, per-type rates and accumulated. It's completely normal for it to exceed your Claude Code plan's subscription cost.</p>
<p><strong>Note</strong>: This metric is more critical if you're paying per raw API call. A subscription gives you a usage allowance with increased but bounded rate limits.</p>
<p>Test the following query in Prometheus first (<code>localhost:9090/query</code>):</p>
<pre><code class="language-promql">sum(increase(claude_code_cost_usage_USD_total[10m]))
</code></pre>
<p><code>increase(...[10m])</code> gives the counter's growth over the last 10 minutes. <code>sum(...)</code> with no <code>by</code> clause collapses the per-attribute series (<code>model</code>, <code>effort</code>, and others) into one number.</p>
<p>For a Grafana panel, swap the fixed <code>[10m]</code> window for the <a href="https://grafana.com/docs/grafana/latest/visualizations/dashboards/variables/global-variables/"><code>$__range</code></a> built-in variable so the value follows the dashboard's time picker:</p>
<pre><code class="language-promql">sum(increase(claude_code_cost_usage_USD_total[$__range]))
</code></pre>
<p>To add it as a panel, open Explore, select Prometheus as the data source, and run the query. The result depends on how much you've used Claude Code in the window.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/99c0e78a-323b-412b-8952-685359644ad6.png" alt="Grafana USD Total Stat - Metrics explorer view of the query." style="display: block;" width="2840" height="1338" loading="lazy">

<p>On adding to the dashboard you should get more Panel Options. Select the <code>Stat</code> panel:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/d0e45c4a-f37e-4eb5-aed0-5929efe8a8fb.png" alt="Total USD Stat Panel" style="display: block;" width="2314" height="1440" loading="lazy">

<p>This panel needs to be added to the Grafana dashboard.</p>
<h4 id="heading-total-token-usage">Total Token Usage</h4>
<p><code>claude_code_token_usage_tokens_total</code> is the cumulative token count, with the same counter shape as the USD cost metric. Read a raw series in Prometheus first to see which labels you can aggregate by. A single series looks like:</p>
<pre><code class="language-text">claude_code_token_usage_tokens_total{effort="high", exported_job="claude-code", instance="otel-collector:8889", job="otel-collector", model="claude-sonnet-5", otel_scope_name="com.anthropic.claude_code", otel_scope_version="2.1.252", query_source="auxiliary", session_id="e0b9795b-4da3-4171-8fa6-a2866bf44d86", terminal_type="ssh-session", type="cacheCreation"}    213730
</code></pre>
<p>The trailing number is the counter value. Key attributes you'll be working with are <code>type</code>, <code>model</code>, and <code>effort</code>. The <a href="#heading-token-usage-by-type">Token Usage by Type</a> chart below groups on <code>type</code>.</p>
<p>The window total is the same query shape as <a href="#heading-total-usd-spent">Total USD Spent</a>, with the token counter:</p>
<pre><code class="language-promql">sum(increase(claude_code_token_usage_tokens_total[$__range]))
</code></pre>
<h4 id="heading-tokens-per-usd">Tokens Per USD</h4>
<p>Unlike the previous two metrics, this is a derived figure, calculated over a time window as Total Tokens / Total Cost.</p>
<pre><code class="language-promql">sum(increase(claude_code_token_usage_tokens_total[$__range])) / sum(increase(claude_code_cost_usage_USD_total[$__range]))
</code></pre>
<p>This one number collapses every attribute combination into a single value. Each distinct combination, for example model A at medium effort versus model B at high effort, is its own time series, and the query sums across all of them.</p>
<p>To analyze a specific combination, run the same ratio split by an attribute and compare.</p>
<pre><code class="language-promql">sum by (model) (increase(claude_code_token_usage_tokens_total[$__range]))
  / sum by (model) (increase(claude_code_cost_usage_USD_total[$__range]))
</code></pre>
<p>Swap <code>model</code> for <code>effort</code> or <code>type</code>; the raw series above lists the rest of the labels.</p>
<p>Overall Result:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/71b728df-e4c9-41bd-9b0b-cc0e0bade04f.png" alt="Stat Panel Aggregated Metrics" style="display: block;" width="1556" height="304" loading="lazy">

<p>Stats Panel(6hr window): Total USD Spent ($4.28), Total Tokens Spent (3.02M), and Tokens Per USD (707k).</p>
<h4 id="heading-token-usage-by-type">Token Usage by Type</h4>
<p>You'll see how <code>claude_code_token_usage_tokens_total</code> changes over time, broken down by <code>type</code>. A single stat hides the shape, so use a time-series panel.</p>
<p>The <code>type</code> attribute has four values, which differ a lot in cost:</p>
<ul>
<li><p><code>cacheRead</code>: tokens served from an existing cache entry. They dominate token spend in a long session, and are cheaper than the <a href="https://platform.claude.com/docs/en/about-claude/pricing#prompt-caching">baseline rate</a>.</p>
</li>
<li><p><code>cacheCreation</code>: tokens written into the prompt cache on the first prefix load. Costly.</p>
</li>
<li><p><code>input</code>: new, uncached prompt tokens.</p>
</li>
<li><p><code>output</code>: model-generated tokens.</p>
</li>
</ul>
<p>To also see the total, use two queries: the per-type breakdown and the un-split total for reference.</p>
<pre><code class="language-promql"># per-type breakdown
sum by (type) (increase(claude_code_token_usage_tokens_total[$__rate_interval]))
# total
sum(increase(claude_code_token_usage_tokens_total[$__rate_interval]))
</code></pre>
<p><code>__rate_interval</code> is Grafana's per-step window for time-series panels, the counterpart to the <code>__range</code> used for the stat panels above.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/8f5fabfa-3cb9-4bcb-bd50-6e95c216bb47.png" alt="Graphana Explore Timeseries" style="display: block;" width="2872" height="1062" loading="lazy">

<p>The two queries running in Explore, before saving them as a panel.</p>
<p>After adding to the dashboard:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/e3cda31c-4741-4710-8bbf-de878a608d0e.png" alt="Running token usage by type" style="display: block;" width="2190" height="600" loading="lazy">

<p>Each spike is a burst of Claude Code activity, the flat stretches are idle time. Hovering a point splits the total into the four types: here the total is about 1.0M tokens, of which <code>cacheRead</code> is about 925k (roughly 92%), the rest cacheCreation, output, and input.</p>
<p><strong>TIP</strong>: Token consumption is dominated by <code>cacheRead</code>, which is also the cheapest type.</p>
<h4 id="heading-token-usage-by-model-and-effort">Token Usage by Model and Effort</h4>
<p>This is the concrete version of the breakdown suggested under Tokens Per USD: which <code>model</code> and <code>effort</code> pairs are actually consuming tokens.</p>
<pre><code class="language-promql">sum by (model, effort) (
  increase(claude_code_token_usage_tokens_total[$__rate_interval])
)
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/47ee6a86-79a3-4bd2-9fff-7a2b64c77a7f.png" alt="Running token usage by model and effort" style="display: block;" width="1686" height="592" loading="lazy">

<p>Here the token counter is grouped by its <code>model</code> and <code>effort</code> attributes. In this window every series is <code>claude-sonnet-5</code> at either <code>medium</code> or <code>high</code> effort, and one burst of <code>medium</code> effort near 18:13 reaches about 2.6M tokens. The number of unique groupings depends on the cardinality of the chosen attributes.</p>
<h3 id="heading-logs">Logs</h3>
<p>A log record is a timestamped event with its full field set attached. You query logs when you want that specific event and the context around it: what happened, when, and with which values.</p>
<p>Metrics are the pre-aggregated form of the same activity. Anything that means counting, summing, or taking percentiles across many records belongs in a metric. If you're aggregating log output downstream, that data should have been a metric from the start.</p>
<p>Logs are the right tool for:</p>
<ol>
<li><p>Per-event context: the full detail of one occurrence, not a rolled-up number.</p>
</li>
<li><p>Discrete or irregular events: a compaction firing, a session start, or an API error.</p>
</li>
<li><p>Post-incident forensics: reading raw records back while debugging after the fact.</p>
</li>
<li><p>Trace correlation: a log line carrying a trace and span ID drops you into the request it came from.</p>
</li>
</ol>
<p>The log backend we're using here is <a href="https://grafana.com/oss/loki/">Loki</a>, queried with <a href="https://grafana.com/docs/loki/latest/query/">LogQL</a>. Running logs through a backend like this buys you:</p>
<ol>
<li><p>Structured fields: filter and compute on named keys instead of regex over text.</p>
</li>
<li><p>Field indexing: label lookups return without scanning every line.</p>
</li>
<li><p>Trace and span correlation: pivot from a log to its trace, or pull every log for one trace.</p>
</li>
<li><p>Time-bounded queries: each query is scoped to a window, keeping the scan cheap.</p>
</li>
</ol>
<p>Grafana reads Loki for dashboards, the same as it does for Prometheus.</p>
<h4 id="heading-compaction-event">Compaction Event</h4>
<p>Compaction is Claude Code trimming its own context when it grows too large. Each compaction emits a log event (<code>event_name="compaction"</code>) carrying the token counts before and after (<code>pre_tokens</code>, <code>post_tokens</code>) and the <code>span_id</code> it happened under, so a query over those events shows how often it fires and how much it reclaims each time.</p>
<p>In Grafana Explore, select Loki as the data source and paste the LogQL below. It selects the compaction events, parses their fields with <a href="https://grafana.com/docs/loki/latest/query/log_queries/"><code>logfmt</code></a>, and derives a per-event reduction percentage with <a href="https://grafana.com/docs/loki/latest/query/template_functions/"><code>label_format</code></a>. The fields only exist once compaction has actually happened, so trigger a few first.</p>
<pre><code class="language-logql">{service_name="claude-code"} | event_name="compaction"
  | logfmt
  | label_format reduction_pct=`{{ printf "%.1f" (mulf (divf (subf .pre_tokens .post_tokens) .pre_tokens) 100) }}`
</code></pre>
<p>Deriving <code>reduction_pct</code> for each record is fine here because it stays per-event. A running average across compactions would belong in a metric.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/a001cb54-bbeb-4a6c-931c-41ac6b7f7640.png" alt=" LogQL query for compaction event against Loki data source." style="display: block;" width="2866" height="1500" loading="lazy">

<p>The <code>label_format</code> line adds a <code>reduction_pct</code> label. To show it as a table, switch the panel to Table view and add three Grafana transformations:</p>
<ol>
<li><p>Extract fields from the labels object.</p>
</li>
<li><p>Filter fields by name to keep Time, pre_tokens, post_tokens, reduction_pct, and span_id.</p>
</li>
<li><p>Convert field type to turn pre_tokens, post_tokens, and reduction_pct into numbers.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/921de4bb-1f19-48de-8447-200fd2d1f875.png" alt="Compaction events with pre/post token counts and the derived reduction percentage." style="display: block;" width="1283" height="803" loading="lazy">

<h3 id="heading-tracing">Tracing</h3>
<p><a href="https://opentelemetry.io/docs/concepts/signals/traces/">Tracing</a> provides a detailed picture of the full path a request takes through an application, from start to completion. Some fundamental concepts behind tracing:</p>
<ul>
<li><p><strong>Span</strong>: a timed operation representing a unit of work. Building block for traces. All trace data is recorded as a sequence of spans, and each has a type, its operation name:</p>
<ul>
<li><p><code>claude_code.interaction</code>: one prompt and everything Claude Code does to answer it. Normally the root span, so one interaction is effectively one trace.</p>
</li>
<li><p><code>claude_code.llm_request</code>: a single model call inside an interaction.</p>
</li>
<li><p><code>claude_code.tool</code>: a single tool call inside an interaction (<code>Bash</code>, <code>Write</code>, <code>Agent</code>, and so on).</p>
</li>
</ul>
</li>
<li><p><strong>Trace</strong>: a tree of spans representing a request path from start to completion.</p>
</li>
<li><p><strong>Session</strong>: one Claude Code run, identified by <code>session.id</code>. It can result in many interactions and traces.</p>
</li>
<li><p><strong>Subagent</strong>: a nested Claude Code instance started by the <a href="https://code.claude.com/docs/en/sub-agents"><code>Agent</code> tool</a>, running its own interactions.</p>
</li>
</ul>
<p><a href="https://www.jaegertracing.io/">Jaeger</a> is the tracing backend used here. The Collector forwards spans to it over OTLP. Jaeger stores them and lets you <a href="https://www.jaegertracing.io/docs/latest/frontend-ui/">search traces</a> by service and span tags and inspect each one as a span tree. Everything below uses its UI at <code>localhost:16686</code>.</p>
<h4 id="heading-generating-traces">Generating Traces</h4>
<p>To generate trace data, this guide will use a test prompt to spawn agents and prepare some text. This prompt was tested with Sonnet 5 at medium effort.</p>
<p>You can paste the prompt directly into Claude Code:</p>
<pre><code class="language-text">Spawn 4 subagents in parallel, one per topic below. Each subagent researches its topic from your own knowledge and returns a ~150-word summary with 3 key points. Do not have them read files or run commands.
Topics:
1. How TCP congestion control works
2. The CAP theorem
3. How DNS resolution works
4. What a Bloom filter is

Once all 4 return, combine the summaries into one markdown document and write it to summary.md
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/ace08f8f-ff14-4e1e-aefa-2c23aea9ec5c.png" alt="Snapshot when running the prompt." style="display: block;" width="2288" height="926" loading="lazy">

<p><strong>Note</strong>: Ask Claude Code for the session_id in the same session after the prompt finishes. This will be used to find related traces in Jaeger.</p>
<h4 id="heading-trace-by-session-id">Trace By Session ID</h4>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/bdbee8ee-99e0-4d2e-add7-b1689193cb4a.png" alt="Finding traces by session_id." style="display: block;" width="1893" height="557" loading="lazy">

<p>The search filters by <code>service = claude-code</code> and the tag <code>session.id=&lt;id&gt;</code>. It returns 6 traces, all rooted at <code>claude_code.interaction</code>, with span counts from 1 to 20 and durations from about 1 second to 33 seconds.</p>
<p>The list alone doesn't say which trace did what. Going through them by hand, or scripting it against the trace API for a real session, gives the following:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Trace Name</th>
<th>Spans</th>
<th>Duration</th>
<th>llm_calls</th>
<th>tools</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>claude_code.interaction</td>
<td>1</td>
<td>1.4s</td>
<td>0</td>
<td>–</td>
</tr>
<tr>
<td>2</td>
<td>claude_code.interaction</td>
<td>3</td>
<td>4.6s</td>
<td>2</td>
<td>–</td>
</tr>
<tr>
<td>3</td>
<td>claude_code.interaction</td>
<td>1</td>
<td>5.4s</td>
<td>0</td>
<td>–</td>
</tr>
<tr>
<td>4</td>
<td>claude_code.interaction</td>
<td>1</td>
<td>2.5s</td>
<td>0</td>
<td>–</td>
</tr>
<tr>
<td>5</td>
<td>claude_code.interaction</td>
<td>20</td>
<td>15.7s</td>
<td>7</td>
<td>Agent(x4)</td>
</tr>
<tr>
<td>6</td>
<td>claude_code.interaction</td>
<td>15</td>
<td>32.5s</td>
<td>5</td>
<td>ScheduleWakeup(x2), Write(x1)</td>
</tr>
</tbody></table>
<p>A few observations:</p>
<ul>
<li><p>Half the traces are noise. Traces 1, 3, and 4 are single-span interactions with no model call or tool, an idle session being pinged. Trace 2 is a brief exchange. Only Traces 5 and 6 are the run.</p>
</li>
<li><p>The parallel dispatch is a single interaction. Trace 5 fires all four <code>Agent</code> calls inside one <code>claude_code.interaction</code>. Their nested model calls (7 to 10 seconds each) overlap, so the interaction finishes in about 16 seconds despite roughly 35 seconds of combined subagent LLM time.</p>
</li>
<li><p>Each subagent's model call is nested under its <code>Agent</code> span and carries an <code>agent_id</code>, so you can tell the four apart.</p>
</li>
<li><p><code>agent_id</code> is opaque. There's no <code>agent.name</code> or <code>skill.name</code>. The trace tells you four subagents ran and how long each took, not which topic each was given.</p>
</li>
<li><p>Spans carry token counts but no USD cost. Each <code>claude_code.llm_request</code> has <code>input_tokens</code>, <code>output_tokens</code>, <code>cache_read_tokens</code>, and <code>cache_creation_tokens</code>, but no USD figure.</p>
</li>
<li><p>The write is a separate, later interaction. Trace 6 has no <code>Agent</code> spans: one <code>claude_code.llm_request</code> of about 23 seconds produces the combined markdown, then a short <code>Write</code>. The two <code>ScheduleWakeup</code> spans are background coordination.</p>
</li>
</ul>
<p><strong>TIP</strong>: From the trace you get the four subagent calls, each with an <code>agent_id</code>, token counts, and timing, but no span says which topic a subagent was handed. In contrast, Metrics can provide attribution by <code>model</code>, <code>effort</code>, and skill.</p>
<p><strong>CAUTION</strong>: <code>user_prompt</code> is redacted by default on interaction spans. OTEL_LOG_USER_PROMPTS=1 disables this and logs raw prompt text. Avoid enabling it in multi-user/tenant environments since it exposes prompt content to anyone with access to the telemetry backend.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>This guide was an end-to-end walkthrough of observability in Claude Code, enabling its telemetry and collecting each of the three signals in a local backend for analysis.</p>
<p>Metrics give you the ability to dissect cumulative cost and usage readings by attribute over a chosen time period. That matters most in a shared or multi-tenant setup, where cost isn't tied to a single owner and someone still has to account for it.</p>
<p>Logs are records of individual events, useful for digging into exactly what changed during one, like compaction.</p>
<p>Traces show how one prompt expands into subagents and model calls, with timing and token counts on each. That's the starting point for debugging or tightening a complex or multi-agent prompt, though the spans don't yet record which prompt or skill drove a given call.</p>
<p>Some of this telemetry is behind the Enhanced Telemetry beta, so span names and attributes can still change, and gaps like per-call attribution may close as it matures. It's worth re-checking the <a href="https://code.claude.com/docs/en/monitoring-usage">monitoring docs</a> as the surface settles.</p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://platform.claude.com/docs/en/about-claude/pricing#prompt-caching">Claude Pricing: Prompt Caching</a></p>
</li>
<li><p><a href="https://code.claude.com/docs/en/monitoring-usage">Claude Code: Monitoring Usage</a></p>
</li>
<li><p><a href="https://prometheus.io/docs/concepts/metric_types/#counter">Prometheus: Counter Metric Type</a></p>
</li>
<li><p><a href="https://www.jaegertracing.io/docs/latest/frontend-ui/">Jaeger: Finding Traces</a></p>
</li>
<li><p><a href="https://grafana.com/docs/loki/latest/query/log_queries/">Loki: LogQL Log Queries</a></p>
</li>
<li><p><a href="https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/stat/">Grafana: Stat Panel</a></p>
</li>
<li><p><a href="https://opentelemetry.io/docs/collector/configuration/">OpenTelemetry Collector: Configuration</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How OpenTelemetry Works: A Complete Guide ]]>
                </title>
                <description>
                    <![CDATA[ If you’re a software developer or DevOps engineer, you've probably come across OpenTelemetry. It comes up a lot, especially when talking about observability, monitoring, or debugging distributed syste ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-opentelemetry-works/</link>
                <guid isPermaLink="false">6a999540c11d1e5beaccfefc</guid>
                
                    <category>
                        <![CDATA[ OpenTelemetry ]]>
                    </category>
                
                    <category>
                        <![CDATA[ observability ]]>
                    </category>
                
                    <category>
                        <![CDATA[ distributed tracing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloud native ]]>
                    </category>
                
                    <category>
                        <![CDATA[ monitoring ]]>
                    </category>
                
                    <category>
                        <![CDATA[ OTLP ]]>
                    </category>
                
                    <category>
                        <![CDATA[ opentelemetry collector ]]>
                    </category>
                
                    <category>
                        <![CDATA[ metrics ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Logs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ traces ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chosen Vincent ]]>
                </dc:creator>
                <pubDate>Thu, 03 Sep 2026 15:41:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/32307170-27b3-463c-bae9-da3dbfd2a634.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you’re a software developer or DevOps engineer,&nbsp;you've probably come across OpenTelemetry. It comes up a lot, especially when talking about observability, monitoring, or debugging distributed systems.</p>
<p>You might even know the basic definition, but knowing what OpenTelemetry is vs how it actually works are two different things.</p>
<p>By the end of this guide, you'll understand how OpenTelemetry works end-to-end, from the moment a request enters your application to the moment you can see it in your observability backend. You'll learn how traces, spans, context propagation, and exporters all fit together into one pipeline.</p>
<p>If you're completely new to OpenTelemetry, don't worry: the next section will get you up to speed before we go any further.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-opentelemetry">What is OpenTelemetry?</a></p>
</li>
<li><p><a href="#heading-how-opentelemetry-works">How OpenTelemetry Works</a></p>
<ul>
<li><p><a href="#heading-step-1-instrument-your-application">Step 1: Instrument Your Application</a></p>
</li>
<li><p><a href="#heading-step-2-opentelemetry-creates-telemetry-signals">Step 2: OpenTelemetry Creates Telemetry Signals</a></p>
</li>
<li><p><a href="#heading-step-3-traces-follow-requests-through-your-application">Step 3: Traces Follow Requests Through Your Application</a></p>
</li>
<li><p><a href="#heading-step-4-context-propagation-connects-work-across-services">Step 4: Context Propagation Connects Work Across Services</a></p>
</li>
<li><p><a href="#heading-step-5-the-opentelemetry-sdk-processes-the-telemetry">Step 5: The OpenTelemetry SDK Processes the Telemetry</a></p>
</li>
<li><p><a href="#heading-step-6-exporters-send-the-telemetry">Step 6: Exporters Send the Telemetry</a></p>
</li>
<li><p><a href="#heading-step-7-the-opentelemetry-collector-receives-and-processes-the-data">Step 7: The OpenTelemetry Collector Receives and Processes the Data</a></p>
</li>
<li><p><a href="#heading-step-8-an-observability-backend-stores-and-analyzes-the-telemetry">Step 8: An Observability Backend Stores and Analyzes the Telemetry</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-putting-the-opentelemetry-flow-together">Putting the OpenTelemetry Flow Together</a></p>
</li>
<li><p><a href="#heading-do-you-need-every-opentelemetry-component">Do You Need Every OpenTelemetry Component?</a></p>
</li>
<li><p><a href="#heading-why-use-opentelemetry">Why Use OpenTelemetry?</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-opentelemetry">What is OpenTelemetry?</h2>
<p><a href="https://opentelemetry.io/docs/">OpenTelemetry</a> is an open-source, vendor-neutral observability framework. It gives you a standard way to instrument your application, generate telemetry data, and export that data to any observability backend of your choice.</p>
<p>Before OpenTelemetry, every monitoring tool had its own way of collecting data. If you used Datadog, you’ll have to instrument your app the Datadog way. If you switched to Jaeger, you started over. OpenTelemetry changed that by giving you one standard way to instrument your application, regardless of which backend you use</p>
<p>[!NOTE] OpenTelemetry is not a monitoring platform, dashboard, or data store. It provides the tools and standards for collecting and exporting telemetry from your application to an observability backend, where the data can be stored, queried, and analyzed.</p>
<p>The data OpenTelemetry collects is called telemetry. It's the information your application produces about itself as it runs, and it comes in three forms:</p>
<ul>
<li><p><strong>Traces</strong> tell you how a request traveled through your system.</p>
</li>
<li><p><strong>Metrics</strong> give you numbers, like how many requests per second your app is handling, or how much memory it's using.</p>
</li>
<li><p><strong>Logs</strong> are timestamped records of specific events that happened inside your application.</p>
</li>
</ul>
<h2 id="heading-how-opentelemetry-works">How OpenTelemetry Works</h2>
<p>When a request hits your application, a lot happens behind the scenes. OpenTelemetry's job is to capture all of that activity (the traces, metrics, and logs) and send them to the right place.</p>
<p>Here’s what it looks like:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62bc299d9c913efac56c91a4/238d33f9-2a15-49b4-993b-43c073a48305.png" alt="A flow diagram showing the six stages of the OpenTelemetry pipeline: the application is instrumented, telemetry is processed by the SDK, sent through an exporter, received by the Collector, and finally stored in an observability backend." style="display: block;" width="1600" height="400" loading="lazy">

<p>Each stage has a specific job. Let's walk through them one by one.</p>
<h3 id="heading-step-1-instrument-your-application">Step 1: Instrument Your Application</h3>
<p>Before OpenTelemetry can capture anything, your application needs to be instrumented. Instrumentation is simply the process of adding code that tells OpenTelemetry what to watch and what to record.</p>
<p>There are two ways to instrument your application: automatically or manually.</p>
<h4 id="heading-1-automatic-instrumentation">1. Automatic instrumentation</h4>
<p>This is the easiest one to start with. You add a library to your project, and it instruments your application for you with no changes to your existing code.</p>
<p>For example, if you're running a Node.js Express app, you can add the OpenTelemetry auto-instrumentation package, and it will automatically start capturing incoming HTTP requests, outgoing calls, database queries, and more.</p>
<p>Here's what that setup looks like:</p>
<pre><code class="language-javascript">const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');

const sdk = new NodeSDK({
&nbsp; instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();
</code></pre>
<p>Once this runs before your app starts, OpenTelemetry begins capturing telemetry automatically. For a complete setup guide, see <a href="https://opentelemetry.io/docs/languages/js/getting-started/nodejs/">Getting started with OpenTelemetry in Node.js</a>.</p>
<h4 id="heading-2-manual-instrumentation">2. Manual instrumentation</h4>
<p>Automatic instrumentation covers a lot, but it can't capture everything that happens inside your own code. If you want to track what happens inside a specific function, like how long it takes to process a payment or validate a user, you need to add that yourself.</p>
<p>Here's a simple example. Let’s say you have a function that processes an order:</p>
<pre><code class="language-javascript">function processOrder(orderId) {
  // processing logic
}
</code></pre>
<p>With manual instrumentation, you wrap it like this:</p>
<pre><code class="language-javascript">const { trace } = require('@opentelemetry/api');

const tracer = trace.getTracer('order-service');

function processOrder(orderId) {
  return tracer.startActiveSpan('processOrder', (span) =&gt; {
    // processing logic

    span.end();
  });
}
</code></pre>
<p>What happens is that you created a span. That span now records when <code>processOrder</code> started, when it ended, and how long it took. You'll learn more about spans in Step 3.</p>
<p>For the full manual instrumentation reference, see <a href="https://opentelemetry.io/docs/languages/js/instrumentation/#traces">OpenTelemetry JavaScript instrumentation</a>.</p>
<h3 id="heading-step-2-opentelemetry-creates-telemetry-signals">Step 2: OpenTelemetry Creates Telemetry Signals</h3>
<p>Once your application is instrumented, OpenTelemetry starts producing telemetry data about what your application is doing. That data comes in three forms, called signals, which we've already briefly talked about: <a href="https://opentelemetry.io/docs/concepts/signals/traces/">traces</a>, <a href="https://opentelemetry.io/docs/concepts/signals/metrics/">metrics</a>, and <a href="https://opentelemetry.io/docs/concepts/signals/logs/">logs</a>. Each signal answers a different kind of observability question.</p>
<p><strong>Traces</strong> show you how a request moved through your system, which services it touched, and how long each step took. <strong>Metrics</strong> give you numbers over time, things like request rate, error rate, and memory usage. <strong>Logs</strong> are timestamped records of specific events that happened inside your application.</p>
<p>Here's a quick comparison:</p>
<table>
<thead>
<tr>
<th>Signal</th>
<th>What to shows</th>
<th>Example</th>
<th>Question it answers</th>
</tr>
</thead>
<tbody><tr>
<td>Trace</td>
<td>The journey of a request through your system</td>
<td>A checkout request passing through your API, order service, and database</td>
<td>Why is this request slow? Where did it fail?</td>
</tr>
<tr>
<td>Metric</td>
<td>A measured value over time</td>
<td>200 requests per second, 95ms average response time</td>
<td>Is my application healthy right now?</td>
</tr>
<tr>
<td>Log</td>
<td>A record of a specific event</td>
<td><code>ERROR: payment failed for order #1234</code></td>
<td>What exactly happened at this point in time?</td>
</tr>
</tbody></table>
<p>You don't have to choose between them. In practice, you'll use all three together. A metric tells you something is wrong, a trace shows you where, and a log tells you exactly what happened.</p>
<h3 id="heading-step-3-traces-follow-requests-through-your-application">Step 3: Traces Follow Requests Through Your Application</h3>
<p>When a user sends a request to your application, that request usually touches multiple services before a response comes back. A trace is the complete record of that journey, from the moment the request enters your system to the moment it finishes.</p>
<p>A trace is actually made up of smaller units called <strong>spans</strong>. Each span represents one operation, like an API call, a database query, or a function execution, and together they give you the full picture of what happened.</p>
<p>Every trace gets a unique trace ID, and every span gets its own span ID. The trace ID is what links all the spans together. No matter how many services a request passes through, they all share the same trace ID, so you can follow the request from start to finish in your observability backend.</p>
<p>Here's a simple example. A user places an order, and the request flows through four services:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62bc299d9c913efac56c91a4/f1672972-b0d7-4a60-964a-7583946affad.png" alt="Trace tree diagram showing four spans under trace ID abc123: API Gateway (0–5ms), Order Service (5–20ms), Payment Service (20–45ms), and Database (45–50ms)." style="display: block;" width="1593" height="760" loading="lazy">

<p>Each span has a start time and an end time, so you can see how long each operation took. If something slowed down or failed, you can pinpoint exactly where it happened just by looking at the spans.</p>
<h3 id="heading-step-4-context-propagation-connects-work-across-services">Step 4: Context Propagation Connects Work Across Services</h3>
<p>In Step 3, you saw how a single trace is made up of spans from multiple services. But here's a question you need to ask: how does OpenTelemetry know that a span in your payment service belongs to the same trace as a span in your order service?</p>
<p>Without something connecting them, each service would record its own spans independently. Your API gateway would see one operation, your order service would see another, and your payment service would see a third. They'd look like completely separate requests with no relationship to each other, which makes debugging across services nearly impossible.</p>
<p>That's where context propagation comes in. As a request moves from one service to another, OpenTelemetry attaches the trace context to it, typically as HTTP headers. That context carries the trace ID and the parent span ID, so every service that handles the request knows which trace it belongs to and where it sits in the chain.</p>
<p>Here's what that looks like in practice:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62bc299d9c913efac56c91a4/526efe4d-2d79-4cdb-b42d-80d8b304312a.png" alt="Context propagation diagram showing trace ID abc123 traveling across API Gateway (span-id: 001), Order Service (span-id: 002), and Payment Service (span-id: 003) via HTTP headers." style="display: block;" width="1600" height="700" loading="lazy">

<p>All three services share the same trace ID. That's what lets your observability backend connect the spans together into one complete trace.</p>
<p>OpenTelemetry doesn't invent its own rules for this. It follows the <a href="https://opentelemetry.io/docs/concepts/context-propagation/#propagation">W3C Trace Context</a> standard, a widely adopted specification that defines how trace context should be formatted and passed between services, so it works consistently across different languages, frameworks, and vendors.</p>
<p>The good news is that if you're using automatic instrumentation, context propagation happens automatically. OpenTelemetry handles the headers for you, so you don't have to think about it unless you're working with a custom transport or a non-standard setup.</p>
<h3 id="heading-step-5-the-opentelemetry-sdk-processes-the-telemetry">Step 5: The OpenTelemetry SDK Processes the Telemetry</h3>
<p>At this point, OpenTelemetry is capturing telemetry and keeping traces connected across services. But between the moment a span is created and the moment it leaves your application, something has to process it. That's the SDK's job.</p>
<p>When your instrumented code creates a span, it does that through the OpenTelemetry API. The API is what you interact with as a developer, things like <code>trace.getTracer()</code> and <code>tracer.startActiveSpan()</code>. But the API alone doesn't process or send anything. It needs the SDK behind it to actually do the work.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62bc299d9c913efac56c91a4/e35443fa-2eff-461d-983a-d6bf3bcd5163.png" alt="A flow diagram showing what happens inside OpenTelemetry before data leaves your application: instrumentation creates telemetry, the API receives it, the SDK processes it, a processor prepares it, and the exporter sends it out." style="display: block;" width="1600" height="400" loading="lazy">

<p>Once the SDK receives the telemetry, it runs it through a processor. The processor is responsible for things like batching spans together before sending them, adding extra attributes, or filtering out data you don't need. The most common one you'll see is the <code>BatchSpanProcessor</code>, which groups spans and exports them in batches rather than one at a time, making it more efficient in production.</p>
<p>Before the processor even runs, the SDK also handles sampling. Sampling lets you control how much telemetry you actually collect. In high-traffic applications, recording every single span would generate an enormous amount of data. With sampling, you can tell the SDK to only capture a percentage of traces, which keeps your costs and data volume manageable without losing visibility.</p>
<p>Once the processor is done, it hands the data to the exporter, which is what actually sends it to its destination. You'll see how that works in the next step.</p>
<h3 id="heading-step-6-exporters-send-the-telemetry">Step 6: Exporters Send the Telemetry</h3>
<p>The exporter's job is simple: take the telemetry the SDK prepared and send it to whatever destination you've configured.</p>
<p>OpenTelemetry uses OTLP, the OpenTelemetry Protocol, to transport telemetry data. It's a standard wire protocol designed specifically for transmitting traces, metrics, and logs, and it runs over either HTTP or gRPC.</p>
<p>[!NOTE] OTLP and OpenTelemetry are not the same thing. OpenTelemetry is the full framework, covering instrumentation, the SDK, the Collector, and more. OTLP is just the protocol it uses to transport data.</p>
<p>Although OTLP is the default, not every exporter uses it. Some exporters send data directly to specific backends in their own format, like Jaeger or Prometheus. So depending on your setup, you might use an OTLP exporter to send data to a Collector or backend, or a vendor-specific exporter to send it directly."</p>
<h3 id="heading-step-7-the-opentelemetry-collector-receives-and-processes-the-data">Step 7: The OpenTelemetry Collector Receives and Processes the Data</h3>
<p>The OpenTelemetry Collector is a standalone service that sits between your application and your observability backend. It receives telemetry data, processes it, and forwards it to one or more destinations.</p>
<p>Using the Collector is common but not mandatory. You can configure your exporter to send data directly to your backend and skip the Collector entirely. But in most production setups, teams add a Collector because it gives them a central place to manage telemetry, without touching application code.</p>
<p>The Collector has three stages:</p>
<ul>
<li><p><strong>Receivers</strong> accept incoming telemetry from your applications, typically over OTLP.</p>
</li>
<li><p><strong>Processors</strong> transform the data at the Collector level, things like batching spans, filtering out noise, or adding attributes before forwarding.</p>
</li>
<li><p><strong>Exporters</strong> send the processed data to your backend, or multiple backends if needed.</p>
</li>
</ul>
<p>Here's a minimal Collector configuration:</p>
<pre><code class="language-yaml">receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  batch:

exporters:
  otlphttp:
    endpoint: https://your-backend.com

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp]
</code></pre>
<p>This config accepts traces over gRPC, batches them for efficiency, and forwards them to an observability backend over HTTP.</p>
<p>The Collector can also receive data from multiple applications simultaneously and route it to one or more destinations. So instead of each application shipping telemetry directly to your backend, they all send it to the Collector, and the Collector handles the rest.</p>
<p>For the full configuration, see the <a href="https://opentelemetry.io/docs/collector/configuration/">OpenTelemetry Collector configuration docs</a>.</p>
<h3 id="heading-step-8-an-observability-backend-stores-and-analyzes-the-telemetry">Step 8: An Observability Backend Stores and Analyzes the Telemetry</h3>
<p>This is where OpenTelemetry's job ends. Once your telemetry leaves the Collector, it arrives at your observability backend.</p>
<p>The backend is what stores your data, lets you query it, and gives you the dashboards and alerts you actually interact with day to day. OpenTelemetry doesn't provide any of that. It gets the data there, and the backend does the rest.</p>
<p>A few popular backends that support OpenTelemetry natively:</p>
<ul>
<li><p><strong>Open-source:</strong> Jaeger, Prometheus, Grafana Tempo</p>
</li>
<li><p><strong>Commercial:</strong> Datadog, New Relic, Honeycomb, Dynatrace, Elastic, Lightstep, Grafana Cloud, Middleware.</p>
</li>
</ul>
<p>Once your data is in the backend, you can search through traces to debug a slow request, build dashboards to monitor your application's health, and set up alerts when something goes wrong.</p>
<h2 id="heading-putting-the-opentelemetry-flow-together">Putting the OpenTelemetry Flow Together</h2>
<p>Let’s assume a user initiates a bank transfer on a mobile banking app. The request hits your API gateway, and because your application is instrumented, OpenTelemetry immediately starts capturing what’s happening. It creates a span for the incoming request and assigns it a trace ID.</p>
<p>As the request moves to the authentication service, context propagation carries that trace ID along in the request headers. The authentication service creates its own span and attaches it to the same trace. The same thing happens when the authentication service calls the transaction service, and when the transaction service hits the database to process the transfer. Four services and four spans, with one trace ID connecting them all.</p>
<p>Meanwhile, the SDK processes the telemetry in the background, runs the spans through the batch processor, and hands them to the exporter. The exporter packages everything into OTLP and sends it to the Collector, which applies your processing rules and forwards it to your observability backend.</p>
<p>Here’s a summary of every component involved in that flow:</p>
<table>
<thead>
<tr>
<th><strong>Component</strong></th>
<th>Role</th>
</tr>
</thead>
<tbody><tr>
<td>Instrumentation</td>
<td>Captures what’s happening inside your application</td>
</tr>
<tr>
<td>API</td>
<td>Exposes the methods your code calls to create spans, metrics, and logs</td>
</tr>
<tr>
<td>SDK</td>
<td>Processes and prepares telemetry for export</td>
</tr>
<tr>
<td>Exporter</td>
<td>Packages and sends telemetry via OTLP</td>
</tr>
<tr>
<td>Collector</td>
<td>Receives, processes, and routes telemetry to your backend</td>
</tr>
<tr>
<td>Observability backend</td>
<td>Stores, queries, and visualizes your telemetry</td>
</tr>
</tbody></table>
<p>From that single transfer request, you now have a complete trace in your backend. When you open your dashboard and search the trace ID, you'll see every service, every span, and every millisecond of that transaction laid out in front of you.</p>
<h2 id="heading-do-you-need-every-opentelemetry-component">Do You Need Every OpenTelemetry Component?</h2>
<p>To be honest, you don’t need every component to get started with OpenTelemetry. The pipeline you’ve seen throughout this article is the full setup, but not every team uses all of that.</p>
<ol>
<li><p><strong>Without the Collector:</strong> Your exporter sends telemetry directly to your backend with nothing in between. Works well for smaller projects or when you're just getting started.</p>
</li>
<li><p><strong>With the Collector:</strong> The more common production setup. Teams add the Collector when they need more control, like routing telemetry to multiple backends, filtering sensitive data, or managing telemetry from dozens of services in one place.</p>
</li>
</ol>
<p>The Collector is powerful, but it’s not mandatory. Start without it if your setup is simple, and add it when you actually need it.</p>
<h2 id="heading-why-use-opentelemetry">Why Use OpenTelemetry?</h2>
<p>You've now seen how every piece of OpenTelemetry fits together. Here's why it's worth using:</p>
<ul>
<li><p><strong>Vendor-neutral instrumentation:</strong> Before OpenTelemetry, switching observability tools meant you had to re-instrument your entire application from scratch. With OpenTelemetry, you instrument once and switch backends freely.</p>
</li>
<li><p><strong>Consistent telemetry across services and languages:</strong> Your backend might be in Go, your microservices in Python, and your data pipeline in Java. OpenTelemetry has SDKs for all of them, and they all produce telemetry in the same format, so your whole stack speaks the same language.</p>
</li>
<li><p><strong>Correlated observability signals:</strong> Traces, metrics, and logs all flow through the same pipeline. So when something goes wrong, a spike in your metrics leads you to a trace, and that trace points you to the exact log entry where things broke down.</p>
</li>
<li><p><strong>It's becoming the industry standard:</strong> Most observability backends already support OpenTelemetry natively. Instead of learning a vendor-specific instrumentation approach every time you adopt a new tool, you learn OpenTelemetry once and it works everywhere.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>OpenTelemetry gives you a standard way to instrument your application, collect telemetry, and ship it to any backend you choose, without locking you into a specific vendor or tool.</p>
<p>If you remember nothing else from this article, remember this: you instrument your application, generate telemetry, process it, export it, and analyze it. Every step has a component behind it, and now you know what each one does.</p>
<p>If you're just getting started, you don't need to set everything up at once. Start with instrumentation, get your telemetry flowing to a backend, and add the Collector when you need it. You can always add more as your needs grow.</p>
<p>If you found this helpful, I'd love to connect. You can find me on <a href="https://www.linkedin.com/in/chosenvincent1">LinkedIn</a> or <a href="https://x.com/ChosenVincent1">X</a>. Feel free to reach out if you have questions or just want to talk about observability and developer tooling.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Trace Multi-Agent AI Swarms with Jaeger v2 ]]>
                </title>
                <description>
                    <![CDATA[ When you run a single AI agent, debugging is straightforward. You read the log, you see what happened. When you run five agents in a swarm, each spawning its own tool calls and producing its own outpu ]]>
                </description>
                <link>https://www.freecodecamp.org/news/multi-agent-ai-swarms-tracing/</link>
                <guid isPermaLink="false">69eaae45904b915438cefb47</guid>
                
                    <category>
                        <![CDATA[ jaeger ]]>
                    </category>
                
                    <category>
                        <![CDATA[ OpenTelemetry ]]>
                    </category>
                
                    <category>
                        <![CDATA[ distributed tracing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ multi-agent systems ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ observability ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Christopher Galliart ]]>
                </dc:creator>
                <pubDate>Thu, 23 Apr 2026 23:41:57 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/308710e6-cfe6-4007-887a-c49a5e2e6b9a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When you run a single AI agent, debugging is straightforward. You read the log, you see what happened.</p>
<p>When you run five agents in a swarm, each spawning its own tool calls and producing its own output, "read the log" stops being a strategy.</p>
<p>I built <a href="https://github.com/HatmanStack/claude-forge">Claude Forge</a> as an adversarial multi-agent coding framework on top of Claude Code. A typical run spawns a planner, an implementer, a reviewer, and a fixer. They evaluate each other's work and loop back when quality checks fail.</p>
<p>But when something went wrong, I had timestamps and text dumps but no way to see which agent was responsible, how long it actually took, or where the tokens went.</p>
<p>Jaeger fixed that. This article covers setting up Jaeger v2 with Docker, wiring it into a multi-agent system through OpenTelemetry, and what I learned along the way.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-distributed-tracing">What Is Distributed Tracing?</a></p>
</li>
<li><p><a href="#heading-why-jaeger-v2">Why Jaeger v2?</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-installing-docker-on-debian">Installing Docker on Debian</a></p>
</li>
<li><p><a href="#heading-setting-up-jaeger-v2">Setting Up Jaeger v2</a></p>
</li>
<li><p><a href="#heading-setting-up-claude-forge-tracing">Setting Up Claude Forge Tracing</a></p>
</li>
<li><p><a href="#heading-understanding-the-span-model">Understanding the Span Model</a></p>
</li>
<li><p><a href="#heading-instrumenting-a-multi-agent-swarm">Instrumenting a Multi-Agent Swarm</a></p>
</li>
<li><p><a href="#heading-viewing-traces-in-the-jaeger-ui">Viewing Traces in the Jaeger UI</a></p>
</li>
<li><p><a href="#heading-lessons-from-the-trenches">Lessons from the Trenches</a></p>
</li>
<li><p><a href="#heading-environment-variable-reference">Environment Variable Reference</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-what-is-distributed-tracing">What Is Distributed Tracing?</h2>
<p>Distributed tracing tracks a single operation as it moves through multiple services. A span is one unit of work with a start time, end time, and key-value attributes. Spans nest into parent-child trees. One tree per operation is one trace.</p>
<p>Microservices people already know this pattern: follow an HTTP request from the gateway through auth, the database, and the cache. Same idea works for multi-agent AI. Follow one swarm invocation from the orchestrator through each subagent and its tool calls.</p>
<p>OpenTelemetry (OTel) is the standard. It gives you SDKs for creating spans and shipping them over OTLP. Jaeger receives that data and renders it as a searchable timeline.</p>
<h2 id="heading-why-jaeger-v2">Why Jaeger v2?</h2>
<p>Jaeger started at Uber and graduated as a CNCF project in 2019. v1 hit end of life in December 2025. v2 is the current release, built on the OpenTelemetry Collector framework. Single binary: collector, query service, and UI. It speaks OTLP natively on port 4317 (gRPC) and 4318 (HTTP). There's no separate collector needed for local work.</p>
<p>One important difference from v1: configuration moved from CLI flags and environment variables to a YAML file. The old <code>-e SPAN_STORAGE_TYPE=badger</code> env vars are silently ignored in v2. The container starts fine but falls back to in-memory storage. I lost two days of traces before noticing. More on the correct setup below.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p><strong>Docker</strong> installed and running.</p>
</li>
<li><p><strong>Claude Code</strong> installed.</p>
</li>
<li><p><strong>Python 3.8+</strong> for the tracing hook.</p>
</li>
<li><p><strong>Claude Forge</strong> or another multi-agent system to instrument.</p>
</li>
</ul>
<h2 id="heading-installing-docker-on-debian">Installing Docker on Debian</h2>
<p>Skip this if you already have Docker. macOS and Windows users can use Docker Desktop. On Debian:</p>
<pre><code class="language-bash">sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
  https://download.docker.com/linux/debian \
  \((. /etc/os-release &amp;&amp; echo "\)VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
newgrp docker
</code></pre>
<p>Ubuntu users: replace both <code>linux/debian</code> URLs with <code>linux/ubuntu</code>.</p>
<h2 id="heading-setting-up-jaeger-v2">Setting Up Jaeger v2</h2>
<h3 id="heading-basic-run">Basic Run</h3>
<p>For quick testing with no persistence:</p>
<pre><code class="language-bash">docker run -d --name jaeger \
  -p 16686:16686 \
  -p 4317:4317 \
  -p 4318:4318 \
  jaegertracing/jaeger:2.17.0
</code></pre>
<p>Port 16686 is the UI. Port 4317 is OTLP/gRPC ingestion. Port 4318 is OTLP/HTTP. Remove the container and your traces are gone.</p>
<h3 id="heading-persistent-storage-with-badger">Persistent Storage with Badger</h3>
<p>v2 reads configuration from a YAML file, not environment variables. Save this as <code>~/.local/share/jaeger/config.yaml</code>:</p>
<pre><code class="language-yaml">service:
  extensions: [jaeger_storage, jaeger_query, healthcheckv2]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [jaeger_storage_exporter]
extensions:
  healthcheckv2:
    use_v2: true
    http: { endpoint: 0.0.0.0:13133 }
  jaeger_query:
    storage: { traces: main_store }
  jaeger_storage:
    backends:
      main_store:
        badger:
          directories: { keys: /badger/key, values: /badger/data }
          ephemeral: false
          ttl: { spans: 720h }
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }
processors:
  batch:
exporters:
  jaeger_storage_exporter:
    trace_storage: main_store
</code></pre>
<p>The Jaeger container runs as UID 10001. Docker named volumes default to root ownership. Without fixing permissions first, the container crash-loops with <code>mkdir /badger/key: permission denied</code>.</p>
<p>Pre-create the volume and fix ownership:</p>
<pre><code class="language-bash">docker volume create jaeger-data

docker run --rm \
  -v jaeger-data:/badger \
  alpine sh -c "mkdir -p /badger/data /badger/key &amp;&amp; chown -R 10001:10001 /badger"
</code></pre>
<p>Then run Jaeger with the config mounted in:</p>
<pre><code class="language-bash">docker run -d --name jaeger \
  --restart unless-stopped \
  -v ~/.local/share/jaeger/config.yaml:/etc/jaeger/config.yaml:ro \
  -v jaeger-data:/badger \
  -p 16686:16686 \
  -p 4317:4317 \
  -p 4318:4318 \
  jaegertracing/jaeger:2.17.0 \
  --config /etc/jaeger/config.yaml
</code></pre>
<p>Verify persistence by running <code>docker restart jaeger</code> and confirming a previously recorded trace is still there. Hit <code>http://localhost:16686</code> and you should see the UI.</p>
<h2 id="heading-setting-up-claude-forge-tracing">Setting Up Claude Forge Tracing</h2>
<h3 id="heading-installing-claude-forge">Installing Claude Forge</h3>
<p>Install it through the Claude Code plugin marketplace:</p>
<pre><code class="language-bash">/plugin marketplace add hatmanstack/claude-forge
/plugin install forge@claude-forge
/reload-plugins
</code></pre>
<p>The install opens a TUI to confirm scope and settings. After reload, commands use the <code>forge:</code> prefix (for example, <code>/forge:pipeline</code>).</p>
<p>You can also clone the repo from <a href="https://github.com/HatmanStack/claude-forge">GitHub</a>.</p>
<h3 id="heading-installing-the-tracing-hook">Installing the Tracing Hook</h3>
<p>From your target project directory, run the install script. For plugin installs:</p>
<pre><code class="language-bash">cd your-project
forge-trace                # if you set up the alias from the README
# or, without the alias:
bash "$(find ~/.claude -path '*/forge*' -name install-tracing.sh 2&gt;/dev/null | head -1)"
</code></pre>
<p>For clone installs:</p>
<pre><code class="language-bash">cd your-project
bash /path/to/claude-forge/bin/install-tracing.sh
</code></pre>
<p>The script builds a dedicated venv at <code>~/.local/share/claude-forge/venv</code> (prefers <code>uv</code>, falls back to <code>python3 -m venv</code>), installs the OpenTelemetry packages, copies the hook into place, merges hook entries into <code>.claude/settings.local.json</code>, and self-tests against the OTLP endpoint.</p>
<p>Pass <code>--no-settings</code> to skip the settings merge, or <code>--uninstall</code> to tear everything down.</p>
<h3 id="heading-opting-in">Opting In</h3>
<p>Add to your shell init and restart your terminal:</p>
<pre><code class="language-bash">export CLAUDE_FORGE_TRACING=1
</code></pre>
<p>Restart Claude Code, run <code>/pipeline</code>, then check <code>http://localhost:16686</code> for the <code>claude-forge</code> service.</p>
<h2 id="heading-understanding-the-span-model">Understanding the Span Model</h2>
<p>Here's what the hierarchy looks like for a typical swarm run:</p>
<pre><code class="language-plaintext">session: "implement login form with OAuth"        &lt;- root span
├── subagent:planner
│   ├── tool:Write  (Phase-0.md)                  &lt;- mutation spans (on by default)
│   ├── tool:Write  (Phase-1.md)
│   └── subagent_result:planner                   &lt;- duration, token counts, output
├── subagent:implementer
│   ├── tool:Edit   (src/auth.ts)
│   ├── tool:Bash   (npm test)
│   ├── tool:Write  (src/oauth.ts)
│   └── subagent_result:implementer
├── subagent:reviewer
│   └── subagent_result:reviewer
└── session_complete                              &lt;- session totals
</code></pre>
<p>The root span's name comes from the first line of your prompt. Find traces by what you asked for, not by a UUID.</p>
<p>Subagents get an anchor span on start and a result span on completion. The result carries duration, token counts, prompt, and output.</p>
<h3 id="heading-three-tiers-of-detail">Three Tiers of Detail</h3>
<p>Not all inner tool calls are equally interesting. Write, Edit, MultiEdit, and Bash are mutational: small in number, high signal. They tell you what actually changed. Read, Glob, Grep, and WebFetch are navigation: lots of them, mostly noise.</p>
<p>Tracing captures mutations by default. That middle ground turned out to be the right one. Before this change, you either saw nothing inside subagents or you saw 200+ spans per run.</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Subagents</th>
<th>Mutations (Write/Edit/Bash)</th>
<th>Other inner tools</th>
</tr>
</thead>
<tbody><tr>
<td>Default</td>
<td>yes</td>
<td>yes</td>
<td>no</td>
</tr>
<tr>
<td><code>CLAUDE_FORGE_TRACE_INNER=1</code></td>
<td>yes</td>
<td>yes</td>
<td>yes (minus blocklist)</td>
</tr>
<tr>
<td><code>CLAUDE_FORGE_TRACE_MUTATIONS=0</code></td>
<td>yes</td>
<td>no</td>
<td>no (or per INNER)</td>
</tr>
</tbody></table>
<h3 id="heading-span-attributes">Span Attributes</h3>
<p><strong>On</strong> <code>session_complete</code><strong>:</strong> <code>session.tokens.input</code>, <code>session.tokens.output</code>, <code>session.tokens.total</code>, <code>session.tokens.turns</code>, <code>session.duration_ms</code>, <code>user.prompt</code> (first 2KB).</p>
<p><strong>On</strong> <code>subagent_result</code><strong>:</strong> <code>agent.description</code>, <code>agent.prompt</code>, <code>agent.output</code>, <code>agent.duration_ms</code>, <code>agent.is_error</code>, <code>agent.tokens.input</code>, <code>agent.tokens.output</code>.</p>
<p><strong>On</strong> <code>tool:*</code><strong>:</strong> <code>tool.name</code>, <code>tool.input</code>, <code>tool.output</code>, <code>tool.duration_ms</code>, <code>tool.is_error</code>.</p>
<h2 id="heading-instrumenting-a-multi-agent-swarm">Instrumenting a Multi-Agent Swarm</h2>
<h3 id="heading-hook-architecture">Hook Architecture</h3>
<p>Claude Code has lifecycle hooks that fire scripts on specific events. Four matter here:</p>
<ol>
<li><p><strong>UserPromptSubmit</strong> (create the root span),</p>
</li>
<li><p><strong>PreToolUse</strong> (start a span),</p>
</li>
<li><p><strong>PostToolUse</strong> (end it with results), and</p>
</li>
<li><p><strong>Stop</strong> (finalize the trace). Each hook gets a JSON payload on stdin and runs as a subprocess.</p>
</li>
</ol>
<h3 id="heading-sending-spans-with-opentelemetry">Sending Spans with OpenTelemetry</h3>
<p>Here's some minimal Python to get a span into Jaeger:</p>
<pre><code class="language-python">from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource

resource = Resource.create({"service.name": "my-agent-system"})
exporter = OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True)
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)

tracer = trace.get_tracer("agent-tracer")

with tracer.start_as_current_span("my-agent-task") as span:
    span.set_attribute("agent.name", "planner")
    span.set_attribute("agent.tokens.input", 1500)
    span.set_attribute("agent.tokens.output", 800)
</code></pre>
<p>Refresh <code>localhost:16686</code>, pick your service, click "Find Traces."</p>
<h3 id="heading-correlating-pre-and-post-events">Correlating Pre and Post Events</h3>
<p>You need to match each PreToolUse to its PostToolUse. Agent-type tool calls didn't include a <code>tool_use_id</code> in the payload, so I hashed the tool name and input instead. Pre and Post carry identical <code>tool_input</code>, so the hashes line up.</p>
<pre><code class="language-python">import hashlib, json

def correlation_key(tool_name: str, tool_input: dict) -&gt; str:
    content = json.dumps({"tool": tool_name, "input": tool_input}, sort_keys=True)
    return hashlib.sha1(content.encode()).hexdigest()[:16]
</code></pre>
<h3 id="heading-state-across-invocations">State Across Invocations</h3>
<p>Every hook call is a separate process. No shared memory. So I wrote span context to JSON files on Pre and read them back on Post:</p>
<pre><code class="language-plaintext">/tmp/claude-forge-tracing/&lt;session_id&gt;/
├── _root.json              # trace ID, root span context
├── _session_start_ns.json  # timestamp for duration calculation
├── subagent_&lt;hash&gt;.json    # per-subagent span context
└── tool_&lt;hash&gt;.json        # per-tool span context
</code></pre>
<p>File names get sanitized against path traversal. <code>_safe_name()</code> strips everything outside <code>[A-Za-z0-9._-]</code> and falls back to a SHA1 slug.</p>
<h3 id="heading-flushing-without-blocking">Flushing Without Blocking</h3>
<pre><code class="language-python">try:
    provider.force_flush(timeout_millis=1000)
except Exception:
    pass  # Never block the swarm
</code></pre>
<p>I tried 2000ms first and the swarm felt slow. 100ms lost spans on cold TLS connections. 1000ms worked. If Jaeger is down, the swarm keeps running regardless.</p>
<h2 id="heading-viewing-traces-in-the-jaeger-ui">Viewing Traces in the Jaeger UI</h2>
<p>Open <code>http://localhost:16686</code>. Pick <code>claude-forge</code> from the service dropdown. Click "Find Traces."</p>
<p>The trace search filters by operation name, tags, and time range. Since session spans take their name from your prompt, searching "login form" pulls up the runs where you asked for one.</p>
<p>The timeline view is where I spend most of my time. Every span is a horizontal bar, nested by parent-child relationships. I can see the planner took 12 seconds, the implementer 45, the reviewer 8. Click any bar to see token counts, prompts, outputs, error status.</p>
<p>Trace comparison puts two runs side by side. This is good for figuring out why one run succeeded and another did not.</p>
<h2 id="heading-lessons-from-the-trenches">Lessons from the Trenches</h2>
<p><strong>One trace per swarm, not per subagent:</strong> My first version wiped the root span's state file on every Stop event, so each subagent started a new trace. I changed Stop to mark a timestamp while preserving the root.</p>
<p><strong>Use descriptions, not type names:</strong> Subagents all report their type as <code>general-purpose</code>. The description field is where the actual role lives.</p>
<p><strong>Token attribution needs per-agent transcripts:</strong> Claude Code writes subagent transcripts to <code>~/.claude/projects/&lt;project&gt;/&lt;session&gt;/subagents/agent-*.jsonl</code>. Match them via <code>agent-*.meta.json</code>.</p>
<p><strong>Parse boolean env vars explicitly:</strong> <code>bool("0")</code> in Python is <code>True</code>. Use an allowlist: <code>{"1", "true", "yes", "on"}</code>.</p>
<h2 id="heading-environment-variable-reference">Environment Variable Reference</h2>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>CLAUDE_FORGE_TRACING=1</code></td>
<td>Master opt-in. Hook is a no-op without this.</td>
</tr>
<tr>
<td><code>CLAUDE_FORGE_TRACE_MUTATIONS=0</code></td>
<td>Disable default mutation spans (Write/Edit/Bash). On by default.</td>
</tr>
<tr>
<td><code>CLAUDE_FORGE_TRACE_INNER=1</code></td>
<td>Capture all inner tool calls as child spans (off by default).</td>
</tr>
<tr>
<td><code>CLAUDE_FORGE_TRACE_TOOL_BLOCKLIST</code></td>
<td>Comma-separated tools to skip when inner tracing is on. Defaults to <code>Read,Glob,Grep,TodoWrite,NotebookRead</code>.</td>
</tr>
<tr>
<td><code>CLAUDE_FORGE_HOOK_DEBUG=1</code></td>
<td>Enable debug logging of raw hook payloads. Off by default.</td>
</tr>
<tr>
<td><code>CLAUDE_FORGE_HOOK_DEBUG_LOG</code></td>
<td>Override debug log path. Defaults to <code>~/.cache/claude-forge/hook.log</code>.</td>
</tr>
<tr>
<td><code>OTEL_EXPORTER_OTLP_ENDPOINT</code></td>
<td>OTLP/gRPC endpoint. Defaults to <code>http://localhost:4317</code>.</td>
</tr>
</tbody></table>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>Without visibility into the process, you're being inefficient with tokens and your time. Multi-agent swarms cost real money on every run. When an agent fails and retries, or when a reviewer rejects work that was close, you're paying for that blind.</p>
<p>Tracing gives you the map. You find out where the failure modes are. You find out which agents burn tokens going nowhere. A 45-second implementer run might have been 10 seconds with a better planner prompt. But you would never know that without seeing the breakdown.</p>
<p>Get observability in early. Jaeger and OpenTelemetry make it cheap to set up. Once you can see where things go wrong you can actually fix them.</p>
<p>Claude Forge tracing is on the <a href="https://github.com/HatmanStack/claude-forge">main branch</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build End-to-End LLM Observability in FastAPI with OpenTelemetry ]]>
                </title>
                <description>
                    <![CDATA[ This article shows how to build end-to-end, code-first LLM observability in a FastAPI application using the OpenTelemetry Python SDK. Instead of relying on vendor-specific agents or opaque SDKs, we wi ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-end-to-end-llm-observability-in-fastapi-with-opentelemetry/</link>
                <guid isPermaLink="false">69b4379c6e27dd07d920f14c</guid>
                
                    <category>
                        <![CDATA[ observability ]]>
                    </category>
                
                    <category>
                        <![CDATA[ OpenTelemetry ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ FastAPI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Jessica Patel ]]>
                </dc:creator>
                <pubDate>Fri, 13 Mar 2026 16:13:16 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c69a589a-2dce-46a1-ac49-a0d0e2c23c6e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>This article shows how to build end-to-end, code-first LLM observability in a FastAPI application using the OpenTelemetry Python SDK.</p>
<p>Instead of relying on vendor-specific agents or opaque SDKs, we will manually design traces, spans, and semantic attributes that capture the full lifecycle of an LLM-powered request.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-introduction">Introduction</a></p>
</li>
<li><p><a href="#heading-prerequisites-and-technical-context">Prerequisites and Technical Context</a></p>
</li>
<li><p><a href="#heading-why-llm-observability-is-fundamentally-different">Why LLM Observability Is Fundamentally Different</a></p>
</li>
<li><p><a href="#heading-reference-architecture-a-traceable-rag-request">Reference Architecture: A Traceable RAG Request</a></p>
</li>
<li><p><a href="#heading-reference-architecture-explained">Reference Architecture Explained</a></p>
</li>
<li><p><a href="#heading-why-this-design-is-better-than-simpler-alternatives">Why This Design Is Better Than Simpler Alternatives</a></p>
</li>
<li><p><a href="#heading-llm-models-that-work-best-for-this-architecture">LLM Models That Work Best for This Architecture</a></p>
</li>
<li><p><a href="#heading-opentelemetry-primer-llm-relevant-concepts-only">OpenTelemetry Primer (LLM-Relevant Concepts Only)</a></p>
</li>
<li><p><a href="#heading-designing-llm-aware-spans">Designing LLM-Aware Spans</a></p>
</li>
<li><p><a href="#heading-fastapi-example-end-to-end-llm-spans-complete-and-explained">FastAPI Example: End-to-End LLM Spans (Complete and Explained)</a></p>
</li>
<li><p><a href="#heading-semantic-attributes-best-practices-for-llm-observability">Semantic Attributes: Best Practices for LLM Observability</a></p>
</li>
<li><p><a href="#heading-evaluation-hooks-inside-traces">Evaluation Hooks Inside Traces</a></p>
</li>
<li><p><a href="#heading-exporting-and-visualizing-traces-where-this-fits-with-vendor-tooling">Exporting and Visualizing Traces (Where This Fits with Vendor Tooling)</a></p>
</li>
<li><p><a href="#heading-operational-patterns-and-anti-patterns">Operational Patterns and Anti-Patterns</a></p>
</li>
<li><p><a href="#heading-extending-the-system">Extending the System</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-introduction"><strong>Introduction</strong></h2>
<p>Large Language Models (LLMs) are rapidly becoming a core component of modern software systems. Applications that once relied on deterministic APIs are now incorporating LLM-powered features such as conversational assistants, document summarization, intelligent search, and retrieval-augmented generation (RAG).</p>
<p>While these capabilities unlock new user experiences, they also introduce operational complexity that traditional monitoring approaches were never designed to handle.</p>
<p>Unlike conventional software services, LLM systems are probabilistic by nature. The same request may produce slightly different responses depending on factors such as prompt structure, model configuration, retrieval context, and sampling parameters such as temperature or top-p.</p>
<p>In addition, LLM workloads introduce entirely new operational dimensions such as token consumption, prompt construction latency, inference cost, context window limits, and response quality.</p>
<p>These factors mean that a request can appear technically successful from an infrastructure perspective while still producing an incorrect, hallucinated, or low-quality result.</p>
<p>Traditional observability tools typically focus on infrastructure-level signals such as latency, error rate, and throughput. While these metrics remain important, they are insufficient for understanding how an LLM application behaves in production.</p>
<p>Engineers must also understand what prompt was constructed, which documents were retrieved, how many tokens were consumed, which model configuration was used, and how the final response was evaluated. Without this visibility, debugging LLM behavior becomes extremely difficult and operational costs can quickly spiral out of control.</p>
<p>This is where LLM observability becomes essential. Observability for LLM systems extends beyond infrastructure monitoring. It captures the full lifecycle of an AI-driven request — from user input and context retrieval to prompt construction, model inference, post-processing, and quality evaluation.</p>
<p>When implemented correctly, observability allows teams to answer why the model generated a particular response, which retrieval results influenced the output, how much a request cost in terms of tokens, where latency occurred within the request pipeline, and whether the response passed basic quality or safety checks.</p>
<p>This article demonstrates how to implement end-to-end LLM observability in a FastAPI application using OpenTelemetry. Instead of relying on proprietary monitoring agents or opaque vendor SDKs, we take a code-first approach to instrumentation. By explicitly designing traces, spans, and semantic attributes, we gain precise control over how LLM interactions are observed and analyzed.</p>
<p>Throughout the guide, we will walk through a practical architecture for tracing a retrieval-augmented generation (RAG) workflow, where each stage of the request lifecycle is represented as a trace span. We will explore how to design meaningful span boundaries, capture prompt and model metadata safely, record token usage and cost signals, and attach evaluation results directly to traces.</p>
<p>The article also explains how this instrumentation can be exported to any OpenTelemetry-compatible backend such as Jaeger, Grafana Tempo, or LLM-specific platforms like Phoenix.</p>
<p>By the end of this guide, you will understand how to:</p>
<ul>
<li><p>Structure traces so that each user request maps to a single end-to-end LLM interaction</p>
</li>
<li><p>Design span hierarchies that reflect the logical stages of an LLM pipeline</p>
</li>
<li><p>Capture prompt metadata, model configuration, and token usage safely</p>
</li>
<li><p>Attach evaluation and quality signals to traces for deeper analysis</p>
</li>
<li><p>Export observability data to different backends without changing instrumentation</p>
</li>
</ul>
<p>Most importantly, the goal of this article is not simply to demonstrate how to add telemetry to an application. Instead, it aims to show how to think about observability when building LLM-powered systems.</p>
<p>When LLM operations are treated as first-class components within a distributed system, traces become a powerful tool for debugging, optimization, cost management, and continuous improvement of model behavior.</p>
<h2 id="heading-prerequisites-and-technical-context">Prerequisites and Technical Context</h2>
<p>Before following this guide, you should be familiar with the Python programming language, basic web API concepts, and general microservice architecture. Below are some key tools and concepts used in this article.</p>
<h3 id="heading-fastapi-web-framework">FastAPI (Web Framework)</h3>
<p>FastAPI is used as the primary web framework for the application. It is a modern Python framework designed for building high-performance APIs using standard Python type hints. FastAPI simplifies request validation, serialization, and API documentation while remaining lightweight and fast.</p>
<h3 id="heading-large-language-models-llms">Large Language Models (LLMs)</h3>
<p>Large Language Models (LLMs) are the computational core of the example system. An LLM is a model trained on vast amounts of text data to generate or transform language in ways that resemble human communication. In production environments, LLMs are commonly used for tasks such as conversational interfaces, summarization, and question answering.</p>
<h3 id="heading-observability-concept">Observability (Concept)</h3>
<p>Observability is the overarching concept that connects all the technical pieces in this article. At a high level, observability refers to the ability to understand a system's internal behavior by examining the data it produces during execution. Rather than asking whether a system is simply "up" or "down," observability helps answer deeper questions about why a request behaved a certain way, where latency was introduced, or how different components interacted.</p>
<h3 id="heading-opentelemetry-instrumentation-standard">OpenTelemetry (Instrumentation Standard)</h3>
<p>OpenTelemetry is the mechanism used to implement observability within the application. It is an open, vendor-neutral standard for generating telemetry data such as traces, metrics, and logs. By instrumenting key parts of the LLM workflow, we can observe how requests flow through the system, how long each step takes, and what contextual data influenced the final outcome. OpenTelemetry serves as the foundation for collecting this information in a consistent and portable way, independent of any specific monitoring backend.</p>
<h2 id="heading-why-llm-observability-is-fundamentally-different">Why LLM Observability Is Fundamentally Different</h2>
<p>Traditional observability assumes deterministic behavior: the same input produces the same output. LLM systems violate this assumption. The same request can vary due to prompt template changes, retrieval differences, sampling parameters (temperature, top-p), model version upgrades, and context window truncation.​</p>
<p>As a result, teams need visibility into what the model saw, how it was configured, what it retrieved, how long it took, and how much it cost, all correlated to a single user request. Logs alone are insufficient, and metrics lack dimensionality. Distributed traces are the backbone of LLM observability.</p>
<h2 id="heading-reference-architecture-a-traceable-rag-request">Reference Architecture: A Traceable RAG Request</h2>
<p>A typical FastAPI-based RAG service follows this flow:</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/6979762ba2442d262dacf388/50e7fda4-7407-43d6-8f12-045b8e73c7eb.png" alt="FastAPI Based RAG Service" style="display: block;" width="936" height="330" loading="lazy">

<p>Each step is observable, but only if we deliberately instrument it. The goal is one trace per user request, with child spans representing each logical LLM step.</p>
<h2 id="heading-reference-architecture-explained">Reference Architecture Explained</h2>
<h3 id="heading-client-sends-a-request-to-chat">Client Sends a Request to /chat</h3>
<p>The architecture begins when a client sends a request to the <code>/chat</code> endpoint. This request typically contains the user's query along with any session or conversation context required by the application.</p>
<p>Keeping the client interface minimal and well-defined is intentional: it ensures the backend receives a predictable input shape and prevents application-specific logic from leaking into downstream LLM processing.</p>
<p>From an observability perspective, this request marks the start of a single end-to-end trace, allowing every subsequent operation to be correlated back to the original user action.</p>
<h3 id="heading-fastapi-validates-input-and-authenticates-the-user">FastAPI Validates Input and Authenticates the User</h3>
<p>Once the request reaches the service, FastAPI performs schema validation and authentication. Validation guarantees that only well-formed inputs proceed through the pipeline, while authentication ensures that expensive LLM operations are only executed for authorized users.</p>
<p>Placing this step early reduces unnecessary computation and protects the system from abuse. It also improves trace quality by ensuring that all observed requests represent legitimate execution paths rather than malformed or rejected traffic.</p>
<h3 id="heading-retriever-queries-the-vector-database">Retriever Queries the Vector Database​</h3>
<p>After validation, the system queries a vector database to retrieve documents relevant to the user's request. This retrieval step is the foundation of retrieval-augmented generation (RAG). By grounding the LLM in external knowledge, the system improves factual accuracy and reduces hallucinations.</p>
<p>Separating retrieval from generation allows teams to tune similarity thresholds, embedding models, and top-k values independently, and it makes it easier to diagnose whether poor responses are caused by bad retrieval or model behavior.</p>
<h3 id="heading-prompt-is-assembled-using-retrieved-documents">Prompt Is Assembled Using Retrieved Documents</h3>
<p>With relevant documents in hand, the system constructs the final prompt that will be sent to the LLM. This step combines the user query, retrieved context, system instructions, and formatting rules into a single structured prompt.</p>
<p>Making prompt assembly an explicit stage enables prompt versioning, experimentation, and observability. It also provides a natural place to detect issues such as context window overflows or excessive prompt size before invoking the model.</p>
<h3 id="heading-llm-api-is-invoked">LLM API Is Invoked</h3>
<p>The LLM API call is the most expensive and non-deterministic operation in the pipeline, which is why it occurs only after all preparatory work is complete. At this stage, the model receives a fully constructed prompt and produces a response based on its configuration parameters.</p>
<p>This step is the primary focus of latency, cost, and reliability controls such as retries, timeouts, and circuit breakers. From an observability standpoint, this span becomes the anchor for token usage, cost attribution, and prompt-level debugging.</p>
<h3 id="heading-response-is-post-processed-and-returned">Response Is Post-Processed and Returned</h3>
<p>After the LLM returns a response, the system performs post-processing before sending the result back to the client. This may include formatting, filtering, validation, or enrichment of the output. Post-processing acts as a final safeguard against malformed or low-quality responses and ensures consistency with application requirements. It also provides a clean boundary for attaching evaluation signals, such as response length, relevance scores, or truncation indicators, before the request completes.</p>
<h2 id="heading-why-this-design-is-better-than-simpler-alternatives">Why This Design Is Better Than Simpler Alternatives</h2>
<p>This architecture intentionally avoids coupling responsibilities together. Validation, retrieval, prompt construction, model execution, and response handling are all distinct steps. This separation makes the system easier to test, easier to observe, and easier to evolve. When something fails, engineers can identify <em>where</em> and <em>why</em> rather than treating the LLM as a black box.​</p>
<p>Compared to a monolithic "send user input directly to the LLM" approach, this design offers better correctness, lower cost, and higher resilience. It also aligns naturally with distributed tracing, since each block maps cleanly to a trace span with a clear semantic purpose. As the system grows, additional features such as caching, fallback models, or policy enforcement can be added without destabilizing the entire flow.​</p>
<p>Most importantly, this architecture treats the LLM as one component in a larger system, not the system itself. That mindset is essential for building reliable production applications.</p>
<h2 id="heading-llm-models-that-work-best-for-this-architecture">LLM Models That Work Best for This Architecture</h2>
<p>This architecture is model-agnostic, but certain model characteristics work particularly well with retrieval-augmented workflows.</p>
<p>Models with strong instruction-following and reasoning capabilities tend to perform best, especially when prompts include structured context from retrieved documents. General-purpose models such as GPT-4-class systems perform well when accuracy and reasoning depth are critical.</p>
<p>For lower-latency or cost-sensitive use cases, smaller instruction-tuned models can be effective when paired with high-quality retrieval. Open-source models such as LLaMA-derived or Mistral-based systems also fit well into this architecture, particularly when deployed behind a private inference endpoint.​</p>
<p>The key requirement is not the model itself, but how it is used. Models that can reliably ground their responses in provided context, respect system instructions, and produce stable outputs under varying prompts integrate most cleanly into this design. Because retrieval and prompt construction are explicit stages, models can be swapped or compared without changing the overall system structure.</p>
<h2 id="heading-opentelemetry-primer-llm-relevant-concepts-only">OpenTelemetry Primer (LLM-Relevant Concepts Only)</h2>
<p>OpenTelemetry defines three core types of telemetry data: traces, metrics, and logs. For LLM systems, traces are the most important. To make them useful, you need to understand a few building blocks:</p>
<ul>
<li><p>a <strong>trace</strong> represents a single end-to-end request</p>
</li>
<li><p>a <strong>span</strong> is a timed operation within that trace</p>
</li>
<li><p><strong>attributes</strong> are key–value metadata attached to spans</p>
</li>
<li><p><strong>events</strong> are time-stamped annotations</p>
</li>
<li><p><strong>context propagation</strong> ensures child spans attach to the correct parent.</p>
</li>
</ul>
<p>FastAPI’s async nature makes correct context propagation essential, but OpenTelemetry’s Python SDK handles this as long as spans are created correctly.</p>
<p>With those concepts in place, the next step is to wire OpenTelemetry into the app. Start by configuring the OpenTelemetry SDK in FastAPI: define a <code>TracerProvider</code>, attach a <code>Resource</code> (service name and environment), configure an exporter (Jaeger, Tempo, Phoenix, and so on), and enable FastAPI auto-instrumentation.</p>
<h2 id="heading-designing-llm-aware-spans">Designing LLM-Aware Spans</h2>
<h3 id="heading-span-taxonomy">Span Taxonomy</h3>
<p>A clean span hierarchy is critical. In this guide, a single <code>http.request</code> span (usually auto-generated) acts as the root, and it contains child spans such as <code>rag.retrieval</code>, <code>rag.prompt.build</code>, <code>llm.call</code>, <code>llm.postprocess</code>, and, optionally, <code>llm.eval</code>. Each of these spans represents a logical unit of work rather than an implementation detail.</p>
<h3 id="heading-span-boundaries">Span Boundaries</h3>
<p>Getting span boundaries right is just as important as picking the right span names. Avoid extremes like wrapping the entire LLM workflow in one giant span, creating a separate span for every token, or dumping all data into logs.</p>
<p>Instead, aim for a few coarse-grained spans that each represent a meaningful step in the request, enrich them with well-chosen attributes, and use events to mark important milestones within a span rather than splitting everything into smaller spans.</p>
<h3 id="heading-instrumenting-the-llm-call">Instrumenting the LLM Call</h3>
<p>When instrumenting the LLM call, treat it as the most critical span in the trace. Whether you are calling OpenAI, Anthropic, or another provider, start the span immediately before the API request and end it only after the full response (or stream) is complete.</p>
<p>Within that span, capture retries, timeouts, and errors so it becomes the central place for latency analysis, cost attribution, and prompt debugging.</p>
<p>For streaming responses, you can emit events for each chunk to track progress, but avoid creating separate child spans unless you truly need fine-grained timing.</p>
<h2 id="heading-fastapi-example-end-to-end-llm-spans-complete-and-explained">FastAPI Example: End-to-End LLM Spans (Complete and Explained)</h2>
<pre><code class="language-python">from fastapi import FastAPI, Request
from opentelemetry import trace
from opentelemetry.trace import Tracer
from typing import List
import asyncio
import hashlib

# Obtain a tracer instance from OpenTelemetry.
# All spans created with this tracer will be part of the same distributed
# tracing system and exported to the configured backend.
tracer: Tracer = trace.get_tracer(__name__)

# Initialize the FastAPI application.
app = FastAPI()

# Helper functions used by the observable endpoint
async def retrieve_documents(query: str) -&gt; List[str]:
    """
    Simulate document retrieval (e.g., vector search or knowledge base lookup).
    This function represents the retrieval stage in a RAG pipeline.
    In a real system, this might query a vector database or search index.
    """
    await asyncio.sleep(0.05)  # Simulate I/O latency
    return [
        "FastAPI enables high-performance async APIs.",
        "OpenTelemetry provides vendor-neutral observability.",
        "LLM observability requires tracing prompts and tokens.",
    ]


def build_prompt(query: str, documents: List[str]) -&gt; str:
    """
    Construct the final prompt from retrieved documents and the user query.
    Prompt construction is kept separate so it can be observed or modified
    independently if needed (for example, to measure prompt assembly latency).
    """
    context = "\n".join(documents)
    return f"""
Context:
{context}

Question:
{query}
"""


class LLMResponse:
    """
    Minimal abstraction for an LLM response.
    This keeps the example self-contained while still allowing us to attach
    token usage and other metadata for observability.
    """

    def __init__(self, text: str, prompt_tokens: int, completion_tokens: int):
        self.text = text
        self.prompt_tokens = prompt_tokens
        self.completion_tokens = completion_token
    
    @property
    def total_tokens(self) -&gt; int:
        return self.prompt_tokens + self.completion_tokens

async def call_llm(prompt: str) -&gt; LLMResponse:
    """
    Simulate an LLM API call.
    In a real implementation, this would call OpenAI, Anthropic, or another
    provider. The artificial delay represents model latency.
    """
    await asyncio.sleep(0.2)  # Simulate inference time
    response_text = "FastAPI and OpenTelemetry enable end-to-end LLM observability."
    # Token count is approximated here for demonstration purposes.
    prompt_tokens = len(prompt.split())
    completion_tokens = len(response_text.split())
    return LLMResponse(response_text, prompt_tokens, completion_tokens)


def summarize_response(response: LLMResponse) -&gt; str:
    """
    Example post-processing step.
    Post-processing is separated into its own phase so any additional latency
    or errors are not incorrectly attributed to the LLM itself.
    """
    return response.text


# Observable FastAPI endpoint
@app.post("/query")
async def rag_query(request: Request, query: str):
    """
    Handle a single RAG-style request with explicit OpenTelemetry spans.
    This endpoint demonstrates how to create one trace per request, with child
    spans for retrieval, LLM invocation, and post-processing.
    """

    # Create a top-level span for the HTTP request.
    # Even if FastAPI auto-instrumentation is enabled, defining this explicitly
    # allows us to attach domain-specific metadata.
    with tracer.start_as_current_span("http.request") as http_span:
        http_span.set_attribute("http.method", "POST")
        http_span.set_attribute("http.route", "/query")

        # Retrieval phase
        # This span isolates the retrieval step so that relevance issues can be
        # debugged independently of LLM behavior.
        with tracer.start_as_current_span("rag.retrieval") as retrieval_span:
            retrieval_span.set_attribute("rag.top_k", 5)
            retrieval_span.set_attribute("rag.similarity_threshold", 0.8)
            documents = await retrieve_documents(query)

            # Record how many documents were returned.
            # This is a key signal when diagnosing hallucinations
            # or missing context in the final response.
            retrieval_span.set_attribute(
                "rag.documents_returned",
                len(documents),
            )

        # LLM invocation phase
        # This span wraps the actual LLM call and is the primary anchor for
        # latency, cost, and prompt-related analysis.
        with tracer.start_as_current_span("llm.call") as llm_span:
            llm_span.set_attribute("llm.provider", "example")
            llm_span.set_attribute("llm.model", "example-llm")
            llm_span.set_attribute("llm.temperature", 0.7)
            llm_span.set_attribute("llm.prompt_template_id", "rag_v1")

            # Build the final prompt using retrieved context.
            # The raw prompt is intentionally not stored as a span attribute.
            prompt = build_prompt(query, documents)
            
            # Prompt metadata
            prompt_hash = hashlib.sha256(prompt.encode()).hexdigest()
            llm_span.set_attribute("llm.prompt_hash", prompt_hash)
            llm_span.set_attribute("llm.prompt_length", len(prompt))

            response = await call_llm(prompt)

            # Hash the response instead of storing raw text.
            # This allows correlation across traces without exposing content.
            response_hash = hashlib.sha256(
                response.text.encode()
            ).hexdigest()
            llm_span.set_attribute("llm.response_hash", response_hash)

            # Record token usage to enable cost attribution
            # and capacity planning.
            llm_span.set_attribute("llm.usage.prompt_tokens", response.prompt_tokens)
            llm_span.set_attribute("llm.usage.completion_tokens", response.completion_tokens)
            llm_span.set_attribute("llm.usage.total_tokens", response.total_tokens)
            
            # example price per token
            estimated_cost = response.total_tokens * 0.000002
            llm_span.set_attribute("llm.cost_estimated_usd", estimated_cost)

        # Post-processing phase
        # Any transformation after the LLM response is captured here,
        # ensuring inference latency is not overstated.
        with tracer.start_as_current_span("llm.postprocess") as post_span:
            summary = summarize_response(response)
            post_span.set_attribute(
                "llm.summary_length",
                len(summary),
            )

    # Return the final response to the client.
    # All spans above belong to the same distributed trace.
    return {"summary": summary}
</code></pre>
<p>Before examining the full code example, it helps to understand how the instrumentation relates to the observability principles described earlier in this article.</p>
<p>The goal of the example is not simply to show how to create spans, but to demonstrate how a single user request can be represented as a structured trace containing meaningful metadata about each stage of the LLM pipeline.</p>
<p>At a high level, the code follows three key design ideas:</p>
<ol>
<li><p>One trace per user request</p>
</li>
<li><p>One span per logical LLM workflow stage</p>
</li>
<li><p>Semantic attributes attached to spans for debugging, cost tracking, and analysis</p>
</li>
</ol>
<p>Each of these concepts directly corresponds to the observability practices discussed earlier.</p>
<h3 id="heading-top-level-request-span">Top-Level Request Span</h3>
<p>The FastAPI endpoint begins by creating a top-level span called <code>http.request</code>. This span represents the entire lifecycle of the incoming request and serves as the root span for the trace.</p>
<pre><code class="language-python">with tracer.start_as_current_span("http.request") as http_span:
</code></pre>
<p>Although FastAPI can generate HTTP spans automatically through OpenTelemetry auto-instrumentation, explicitly creating this span allows the application to attach domain-specific metadata such as route names or user identifiers.</p>
<p>Attributes such as the HTTP method and route are attached here:</p>
<pre><code class="language-python">http_span.set_attribute("http.method", "POST")
http_span.set_attribute("http.route", "/query")
</code></pre>
<p>This ensures that every trace can be easily filtered by endpoint when analyzing production traffic.</p>
<h3 id="heading-retrieval-span">Retrieval Span</h3>
<p>The next span captures the retrieval phase of the RAG pipeline:</p>
<pre><code class="language-python">with tracer.start_as_current_span("rag.retrieval") as retrieval_span:
</code></pre>
<p>This span isolates the vector search or knowledge retrieval step from the rest of the pipeline. If users report irrelevant answers, engineers can inspect this span to determine whether the issue originates from poor retrieval results rather than model behavior.</p>
<p>Several semantic attributes are attached here:</p>
<ul>
<li><p><code>rag.top_k</code> – number of documents requested</p>
</li>
<li><p><code>rag.similarity_threshold</code> – similarity cutoff used for filtering results</p>
</li>
<li><p><code>rag.documents_returned</code> – number of documents actually retrieved</p>
</li>
</ul>
<p>These attributes align with the RAG observability signals discussed in the earlier section of the article.</p>
<h3 id="heading-llm-invocation-span">LLM Invocation Span</h3>
<p>The most important span in the trace is the <code>llm.call</code> span, which wraps the actual model invocation.</p>
<pre><code class="language-python">with tracer.start_as_current_span("llm.call") as llm_span:
</code></pre>
<p>This span captures the latency, configuration, and token usage associated with the LLM request. In production systems, it becomes the primary location for analyzing model behavior and cost.</p>
<p>Key attributes recorded in this span include:</p>
<ul>
<li><p><code>llm.provider</code> – the model provider (OpenAI, Anthropic, etc.)</p>
</li>
<li><p><code>llm.model</code> – the specific model version</p>
</li>
<li><p><code>llm.temperature</code> – sampling parameter controlling response randomness</p>
</li>
<li><p><code>llm.prompt_template_id</code> – identifier for the prompt template used</p>
</li>
</ul>
<p>These attributes make it possible to correlate changes in model configuration with downstream quality or cost changes.</p>
<h3 id="heading-prompt-handling-and-privacy">Prompt Handling and Privacy</h3>
<p>Instead of storing the full prompt or response text directly in the trace, the example demonstrates a safer practice: hashing sensitive data.</p>
<pre><code class="language-python">response_hash = hashlib.sha256(response.text.encode()).hexdigest()
</code></pre>
<p>The resulting hash is stored as a span attribute:</p>
<pre><code class="language-python">llm_span.set_attribute("llm.response_hash", response_hash)
</code></pre>
<p>This approach allows engineers to correlate repeated responses across traces without exposing potentially sensitive content in observability systems.</p>
<h3 id="heading-token-usage-tracking">Token Usage Tracking</h3>
<p>The <code>llm.call</code> span also records token usage:</p>
<pre><code class="language-python">llm_span.set_attribute(
    "llm.usage.total_tokens",
    response.total_tokens
)
</code></pre>
<p>Capturing token usage at the span level is critical for monitoring cost and efficiency, since token consumption directly determines billing for most LLM providers.</p>
<h3 id="heading-post-processing-span">Post-Processing Span</h3>
<p>Finally, the example includes a <code>llm.postprocess</code> span:</p>
<pre><code class="language-python">with tracer.start_as_current_span("llm.postprocess") as post_span:
</code></pre>
<p>This span represents any transformation applied after the model generates its response. Separating post-processing from the LLM call ensures that additional latency — such as formatting, filtering, or validation — is not incorrectly attributed to the model itself.</p>
<p>An attribute such as response length is recorded here:</p>
<pre><code class="language-python">post_span.set_attribute("llm.summary_length", len(summary))
</code></pre>
<p>This can be useful when diagnosing issues such as unexpectedly short or truncated outputs.</p>
<h3 id="heading-how-the-spans-form-a-complete-trace">How the Spans Form a Complete Trace</h3>
<p>When the request finishes, all spans belong to the same distributed trace:</p>
<pre><code class="language-plaintext">http.request
 ├── rag.retrieval
 ├── llm.call
 └── llm.postprocess
</code></pre>
<p>This hierarchy reflects the logical workflow of a retrieval-augmented LLM system. Because each span contains structured metadata, engineers can quickly answer questions such as:</p>
<ul>
<li><p>Was the latency caused by retrieval or model inference?</p>
</li>
<li><p>How many documents influenced the prompt?</p>
</li>
<li><p>Which model configuration produced the response?</p>
</li>
<li><p>How many tokens were consumed?</p>
</li>
<li><p>Was the response post-processed or truncated?</p>
</li>
</ul>
<p>This structured trace design is what transforms observability from simple monitoring into a practical debugging and optimization tool for LLM systems.</p>
<h2 id="heading-semantic-attributes-best-practices-for-llm-observability">Semantic Attributes: Best Practices for LLM Observability</h2>
<p>The goal is not to capture every possible detail, but to record the minimal set of stable, high-signal attributes that enable effective debugging, cost control, and quality analysis in production. Poor attribute design leads to noisy traces, privacy risks, and dashboards that are impossible to reason about.</p>
<h3 id="heading-prompt-response-and-model-metadata">Prompt, Response, and Model Metadata​</h3>
<p>Storing raw prompts is often unsafe and expensive, so it is better to record minimal, structured metadata instead. In practice, this means attaching a stable template identifier with <code>llm.prompt_template_id</code>, a hashed version of the final prompt using <code>llm.prompt_hash</code> (to avoid storing raw text), and a size indicator such as <code>llm.prompt_length</code>, which captures the number of tokens or characters.</p>
<p>You should also always record key inference parameters: <code>llm.provider</code> (for example, "openai" or "anthropic"), <code>llm.model</code> (for example, "gpt-4.1"), <code>llm.temperature</code> and <code>llm.top_p</code> (sampling parameters), <code>llm.max_tokens</code> (the maximum tokens allowed), and <code>llm.stream</code> to indicate whether streaming was enabled, while staying within your organization’s privacy and compliance requirements.</p>
<pre><code class="language-python">
with tracer.start_as_current_span("llm.call") as llm_span:
            llm_span.set_attribute("llm.provider", "example")
            llm_span.set_attribute("llm.model", "example-llm")
            llm_span.set_attribute("llm.temperature", 0.7)
            llm_span.set_attribute("llm.top_p", 0.9)
            llm_span.set_attribute("llm.max_tokens", 512)
            llm_span.set_attribute("llm.stream", False)
            llm_span.set_attribute("llm.prompt_template_id", "rag_v1")

            # Build the final prompt using retrieved context.
            # The raw prompt is intentionally not stored as a span attribute.
            prompt = build_prompt(query, documents)
            
            # Prompt metadata
            prompt_hash = hashlib.sha256(prompt.encode()).hexdigest()
            llm_span.set_attribute("llm.prompt_hash", prompt_hash)
            llm_span.set_attribute("llm.prompt_length", len(prompt))
</code></pre>
<h3 id="heading-token-usage-and-cost-why-this-matters-in-practice">Token Usage and Cost (Why This Matters in Practice)</h3>
<p>Token usage is one of the most common blind spots in LLM systems. Many teams monitor latency and error rates but discover runaway costs only after invoices spike. Because token consumption varies significantly by prompt structure, retrieved context, and model configuration, it must be captured explicitly at the span level.​</p>
<p>The most important practice is to record token usage at the end of the LLM span, once the model has completed inference. This ensures that the values reflect the full request rather than partial or streamed output.</p>
<p>At minimum, capture the attributes:​<code>llm.usage.prompt_tokens</code> ,<code>llm.usage.completion_tokens</code> and <code>llm.usage.total_tokens</code>​.</p>
<pre><code class="language-python">def __init__(self, text: str, prompt_tokens: int, completion_tokens: int):
        self.text = text
        self.prompt_tokens = prompt_tokens
        self.completion_tokens = completion_token
    
    @property
    def total_tokens(self) -&gt; int:
        return self.prompt_tokens + self.completion_tokens

async def call_llm(prompt: str) -&gt; LLMResponse:
    """
    Simulate an LLM API call.
    In a real implementation, this would call OpenAI, Anthropic, or another
    provider. The artificial delay represents model latency.
    """
    await asyncio.sleep(0.2)  # Simulate inference time
    response_text = "FastAPI and OpenTelemetry enable end-to-end LLM observability."
    # Token count is approximated here for demonstration purposes.
    prompt_tokens = len(prompt.split())
    completion_tokens = len(response_text.split())
    return LLMResponse(response_text, prompt_tokens, completion_tokens)
</code></pre>
<p>These values allow you to distinguish between requests that are expensive because of large prompts (often caused by excessive retrieval or poor prompt construction) versus those that are expensive because of long model-generated outputs.</p>
<p>*Where possible, also attach an estimated cost:*​ <code>llm.cost_estimated_usd</code>​</p>
<pre><code class="language-python">    # example price per token
    estimated_cost = response.total_tokens * 0.000002
    llm_span.set_attribute("llm.cost_estimated_usd", estimated_cost)
</code></pre>
<p>This value is typically derived by multiplying token counts by the model's published pricing. Even if the estimate is approximate, it enables powerful analysis. For example, you can identify which endpoints, prompt templates, or user flows are responsible for the highest cumulative cost, rather than relying on coarse, account-level billing dashboards.</p>
<p>Once spans carry the right attributes, the next step is to connect them to output quality, not just system health.</p>
<h2 id="heading-evaluation-hooks-inside-traces">Evaluation Hooks Inside Traces</h2>
<p>This section describes an additional pattern you can layer on top of the core instrumentation in this guide. It is optional and not implemented in the sample code, but it shows how to attach quality signals directly to your traces.</p>
<p>Observability is not just about whether the system stayed up, it is also about whether the model produced a useful answer. Evaluation hooks inside traces let you attach lightweight quality signals directly to the same spans you use for latency and cost.</p>
<p>Inline evaluations are the simplest approach. You can run quick checks synchronously and record the results as span attributes, such as <code>llm.eval.passed</code> for a simple boolean check, <code>llm.eval.relevance_score</code> for an optional numerical score, or flags like <code>llm.eval.hallucination_detected</code> and <code>llm.eval.refusal_detected</code>. These attributes travel with the trace, so you can filter and aggregate on them in your observability backend just like any other field.</p>
<p>For higher accuracy, you can introduce model-based evaluation as a separate step. In this pattern, an evaluator LLM runs asynchronously on the original prompt and response, and its work is captured in a child span (for example, <code>llm.eval</code>) that shares the same trace ID as the main <code>llm.call</code> span. You then attach scores such as relevance, faithfulness, or toxicity to that evaluation span.</p>
<p>Because the evaluation span shares the same trace ID, you can correlate quality regressions with changes in prompts or retrieval.</p>
<h2 id="heading-exporting-and-visualizing-traces-where-this-fits-with-vendor-tooling">Exporting and Visualizing Traces (Where This Fits with Vendor Tooling)</h2>
<p>This code-first observability design is vendor-agnostic. Once traces are emitted using OpenTelemetry, they can be exported to different backends without changing instrumentation.</p>
<p>General-purpose tracing systems like Jaeger and Grafana Tempo help engineers debug latency, errors, and request flow across retrieval, prompting, and model calls, answering how the system behaved. LLM-focused platforms such as Arize Phoenix use the same data but add model-specific insights like prompt clustering, token analysis, and quality correlation.</p>
<p>Because instrumentation stays OpenTelemetry-native, you maintain full control over attributes and trace structure while still using vendor dashboards, and you can switch backends as your needs evolve without touching the application code.</p>
<h2 id="heading-operational-patterns-and-anti-patterns">Operational Patterns and Anti-Patterns</h2>
<p>Effective LLM observability requires disciplined practices. High-volume systems should sample traces to limit overhead, and prompts or responses should be hashed by default to reduce storage and privacy risk. Traces must be treated as production data, with proper access control and retention policies.</p>
<p>Common pitfalls include relying only on vendor SDK traces, logging prompts without trace correlation, or ignoring evaluation signals. These issues fragment visibility and hide quality regressions, especially when observability focuses only on agents instead of full application context.</p>
<h2 id="heading-extending-the-system">Extending the System</h2>
<p>Once traces are reliable, they support advanced capabilities. Metrics like p95 latency can be derived from spans, logs can be linked using trace IDs, and historical traces can power offline evaluation or prompt testing.​</p>
<p>By following OpenTelemetry conventions, the observability stack also stays aligned with emerging LLM semantic standards, keeping the system flexible and future-proof.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>End-to-end LLM observability is not achieved by installing another agent. It is achieved through intentional span design, meaningful semantic attributes, and, where needed, lightweight evaluation hooks.​</p>
<p>By treating LLM calls as first-class operations within distributed traces, you gain faster debugging, controlled costs, safer deployments, and measurable quality improvements. The backend — Jaeger, Tempo, Phoenix — is interchangeable. The instrumentation strategy is not.​</p>
<p>A well-designed trace is the most valuable artifact in a production LLM system.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Debug Kubernetes Apps When Logs Fail You – An eBPF Tracing Handbook ]]>
                </title>
                <description>
                    <![CDATA[ Let’s say your Kubernetes pod crashes at 3am and the logs show nothing useful. By the time you SSH into the node, the container is gone, and you're left guessing what happened in those final moments. This is the reality of debugging modern applicatio... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-debug-kubernetes-apps-when-logs-fail-you-an-ebpf-tracing-handbook/</link>
                <guid isPermaLink="false">694190c566a5d5cb99995f9f</guid>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ eBPF ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ observability ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ OpenTelemetry ]]>
                    </category>
                
                    <category>
                        <![CDATA[ inspektor gadget ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Opaluwa Emidowojo ]]>
                </dc:creator>
                <pubDate>Tue, 16 Dec 2025 17:03:01 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1765899860869/3eadf316-8539-4624-afba-1d4190b6c62a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Let’s say your Kubernetes pod crashes at 3am and the logs show nothing useful. By the time you SSH into the node, the container is gone, and you're left guessing what happened in those final moments.</p>
<p>This is the reality of debugging modern applications. Traditional monitoring wasn't built for containers that live for seconds, services that shift across nodes, or network paths that change constantly.</p>
<p>eBPF changes this. It lets you see <em>inside</em> the kernel itself, watching every system call, every network packet, and every process execution – without modifying a single line of code.</p>
<p>In this tutorial, you will trace a real Kubernetes application using eBPF-powered tools. You’ll learn fundamentals that apply across the entire modern observability ecosystem, with gadgets from the Inspektor Gadget ecosystem.</p>
<p>By the end, you’ll be able to:</p>
<ul>
<li><p>Trace requests as they move through your Kubernetes pods</p>
</li>
<li><p>Observe behavior at the kernel and syscall level</p>
</li>
<li><p>Debug failures that logs and metrics simply can’t explain</p>
</li>
</ul>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p><strong>Knowledge requirements:</strong></p>
<ul>
<li><p>Basic Kubernetes concepts: pods, deployments, services, namespaces</p>
</li>
<li><p>Familiarity with kubectl: <code>get</code>, <code>describe</code>, <code>logs</code>, <code>exec</code></p>
</li>
<li><p>Container basics</p>
</li>
<li><p>Basic Linux concepts: processes, system calls</p>
</li>
</ul>
<p><strong>Technical requirements:</strong></p>
<ul>
<li><p>Kubernetes cluster (local or cloud-based)</p>
</li>
<li><p><code>kubectl</code> installed and configured</p>
</li>
<li><p>Cluster admin permissions</p>
</li>
<li><p>Linux kernel 5.10+ (most managed services have this)</p>
</li>
</ul>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ul>
<li><p><a class="post-section-overview" href="#heading-understanding-ebpf-observability">Understanding eBPF Observability</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-ebpf-tracing-works-without-getting-lost-in-the-kernel">How eBPF Tracing Works (Without Getting Lost in the Kernel)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-your-environment">How to Set Up Your Environment</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-trace-your-first-request-hands-on-tutorial">How to Trace Your First Request: Hands-On Tutorial</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-interpret-traces">How to Interpret Traces</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-real-world-debugging-scenarios">Real-World Debugging Scenarios</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-advanced-tracing-insights">Advanced Tracing Insights</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-best-practices-and-production-considerations">Best Practices and Production Considerations</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-next-steps-and-resources">Next Steps and Resources</a></p>
</li>
</ul>
<h2 id="heading-understanding-ebpf-observability">Understanding eBPF Observability</h2>
<p>eBPF (extended Berkeley Packet Filter) is a technology that allows you to run custom programs inside the Linux kernel without changing kernel code or loading kernel modules.</p>
<p>The Linux kernel is the control center of your operating system. Historically, if you wanted to observe low-level activity (like network packets, system calls, or file operations), you had to rely on kernel changes or kernel modules. Both approaches were fragile, difficult to maintain, and carried real stability and security risks.</p>
<p>eBPF shifts how we approach observability. It provides a safe, sandboxed environment where you can run observability programs directly in the kernel with built-in safety checks that prevent crashes or security vulnerabilities.</p>
<h3 id="heading-why-does-this-matter-for-observability">Why does this matter for observability?</h3>
<p>In traditional observability, you instrument your application code. You add logging statements, metrics libraries, and tracing SDKs. This works, but has significant limitations:</p>
<ul>
<li><p><strong>Code changes are required</strong>: You must modify and redeploy applications</p>
</li>
<li><p><strong>It’s language-specific</strong>: Different languages need different libraries</p>
</li>
<li><p><strong>There will likely be blind spots</strong>: You can only see what you explicitly instrument</p>
</li>
<li><p><strong>The overhead</strong>: Heavy instrumentation slows down applications</p>
</li>
<li><p><strong>Container challenges</strong>: By the time you add instrumentation and redeploy, the problem may have disappeared</p>
</li>
</ul>
<p>eBPF takes a different approach. Instead of instrumenting applications, you instrument the kernel. Since every application ultimately makes system calls to the kernel for network I/O, file operations, and process management, you can observe everything from one vantage point.</p>
<h3 id="heading-the-ebpf-advantage-for-kubernetes">The eBPF advantage for Kubernetes</h3>
<p>Kubernetes adds another layer of complexity. Your application might be spread across multiple containers, pods, and nodes. Traditional APM (Application Performance Monitoring) tools struggle here because containers come and go rapidly, network topology changes constantly, service meshes add routing complexity, and you often don't control application code (think third-party services or legacy applications you can't modify.)</p>
<p>eBPF doesn't care about any of this. It sees all activity at the kernel level, regardless of what language your app is written in, whether it's containerized, how many times the pod has been rescheduled, or whether you have access to modify the source code. This universal visibility is why the Cloud Native Computing Foundation (CNCF) and major cloud providers are betting heavily on eBPF for the future of observability.</p>
<h2 id="heading-how-ebpf-tracing-works-without-getting-lost-in-the-kernel">How eBPF Tracing Works (Without Getting Lost in the Kernel)</h2>
<p>When your application runs on Kubernetes, there's a clear separation between user space and kernel space. Your code runs in user space, where it's isolated, safe, and has limited access to system resources. To do anything useful – make network calls, read files, allocate memory – your application must ask the kernel for help. The kernel handles these requests via system calls, commonly called syscalls.</p>
<p>eBPF lets us hook into these syscalls without slowing the system down. It’s like having a CCTV camera at every doorway between user space and kernel space, watching who passes through, when, and what they’re carrying.</p>
<h3 id="heading-a-simple-example-http-request-tracing">A Simple Example: HTTP Request Tracing</h3>
<p>Your application initiates an HTTP GET request, which needs to go through the network stack. To establish a connection, your application first makes a <code>socket()</code> system call to create a network socket. Then it calls <code>connect()</code> to establish a connection to the remote server. Once connected, it uses <code>send()</code> to transmit the HTTP request. Network packets are sent across the wire, and eventually your application calls <code>recv()</code> to receive the response.</p>
<p>With eBPF tools like Inspektor Gadget's Traceloop, you can automatically hook into these syscalls. The eBPF program captures request metadata including source and destination IPs, ports, timing information, and payload sizes. You get a complete trace of the request without touching your application code.</p>
<h3 id="heading-the-ebpf-execution-flow">The eBPF Execution Flow</h3>
<p>Here's what happens under the hood when you run a trace. When you deploy Inspektor Gadget and run a gadget, several things happen behind the scenes. Once deployed, the eBPF program springs into action whenever a traced event occurs.</p>
<p>When your application makes a syscall, the eBPF hook triggers and quickly collects relevant data: timestamps, process IDs, container IDs, pod names, request details, and latency information. This data is sent to user space through eBPF maps, which are efficient data structures for kernel-to-userspace communication.</p>
<p>Inspektor Gadget adds Kubernetes context to raw kernel data. Instead of seeing only process IDs, you can see pod names, namespaces, labels, and other metadata. For example, you can tell that a request originated from the frontend pod in the production namespace and targeted the backend service.</p>
<p>The gadget then presents this information in a format that's immediately useful, whether you're using the CLI or integrating with other observability tools.</p>
<p>eBPF is fast because:</p>
<ul>
<li><p><strong>JIT compilation</strong>: Programs are turned into native machine code for maximum performance</p>
</li>
<li><p><strong>Event-driven</strong>: Only execute when relevant events occur, not continuously polling</p>
</li>
<li><p><strong>Kernel-resident</strong>: No expensive context switching between kernel and user space</p>
</li>
<li><p><strong>Highly optimized</strong>: Typically adds less than 5% overhead even under heavy load</p>
</li>
</ul>
<h3 id="heading-the-tool-inspektor-gadget-amp-traceloop">The Tool: Inspektor Gadget &amp; Traceloop</h3>
<p>For this tutorial, we're using Traceloop, an eBPF-based tool that traces request flows through applications by observing syscalls, network calls, and I/O operations at the kernel level.</p>
<p>Why are we using Traceloop for this tutorial?</p>
<ul>
<li><p>It’s quick to install and run (one command)</p>
</li>
<li><p>The output maps directly to the application’s behavior</p>
</li>
<li><p>It automatically adds Kubernetes context (pod names, namespaces)</p>
</li>
<li><p>You don’t need to make any application code changes</p>
</li>
</ul>
<p>What you'll learn applies beyond Traceloop. All eBPF tracing tools (Pixie, Cilium Hubble, Tetragon) work the same way under the hood. They attach to kernel hooks and collect event data. Once you understand the concepts here, you can use any eBPF observability tool effectively.</p>
<h2 id="heading-how-to-set-up-your-environment">How to Set Up Your Environment</h2>
<p>To get your environment ready for hands-on tracing, we'll verify that your cluster meets the requirements, install Inspektor Gadget, and deploy a sample application to trace.</p>
<h3 id="heading-verify-that-your-cluster-meets-the-requirements">Verify that Your Cluster Meets the Requirements</h3>
<p>Before installing anything, confirm that your Kubernetes cluster is ready for eBPF.</p>
<h4 id="heading-check-your-kubernetes-version">Check your Kubernetes version:</h4>
<pre><code class="lang-bash">kubectl version --short
</code></pre>
<p>You need Kubernetes 1.19 or later. Most modern clusters exceed this requirement, but it's worth verifying.</p>
<h4 id="heading-verify-kernel-version-on-your-nodes">Verify kernel version on your nodes:</h4>
<pre><code class="lang-bash">kubectl get nodes -o wide
</code></pre>
<p>Then check the kernel version on one of your nodes:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># If using a local cluster like minikube or kind</span>
uname -r

<span class="hljs-comment"># For cloud clusters, you might need to check node details</span>
kubectl debug node/&lt;node-name&gt; -it --image=ubuntu -- bash -c <span class="hljs-string">"uname -r"</span>
</code></pre>
<p>You need Linux kernel 5.10 or later for the best eBPF support. Kernel 4.18+ works but with some limitations. If you're using a managed Kubernetes service (GKE, EKS, AKS), you almost certainly have a compatible kernel.</p>
<h4 id="heading-confirm-that-you-have-cluster-admin-permissions">Confirm that you have cluster admin permissions:</h4>
<pre><code class="lang-bash">kubectl auth can-i create deployments --all-namespaces
</code></pre>
<p>This should return "yes". Inspektor Gadget needs elevated permissions to load eBPF programs into the kernel.</p>
<h3 id="heading-install-inspektor-gadget">Install Inspektor Gadget</h3>
<p>You can install Inspektor Gadget in several ways. We'll use the kubectl plugin method as it's the most straightforward for learning.</p>
<h4 id="heading-install-the-kubectl-gadget-plugin">Install the kubectl gadget plugin:</h4>
<pre><code class="lang-bash"><span class="hljs-comment"># Download and install kubectl-gadget</span>
kubectl krew install gadget

<span class="hljs-comment"># Verify installation</span>
kubectl gadget version
</code></pre>
<p>If you don't have krew (the kubectl plugin manager), you can install it first:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Install krew</span>
(
  <span class="hljs-built_in">set</span> -x; <span class="hljs-built_in">cd</span> <span class="hljs-string">"<span class="hljs-subst">$(mktemp -d)</span>"</span> &amp;&amp;
  OS=<span class="hljs-string">"<span class="hljs-subst">$(uname | tr '[:upper:]' '[:lower:]')</span>"</span> &amp;&amp;
  ARCH=<span class="hljs-string">"<span class="hljs-subst">$(uname -m | sed -e 's/x86_64/amd64/' -e 's/\(arm\)\(64\)\?.*/\1\2/' -e 's/aarch64$/arm64/')</span>"</span> &amp;&amp;
  KREW=<span class="hljs-string">"krew-<span class="hljs-variable">${OS}</span>_<span class="hljs-variable">${ARCH}</span>"</span> &amp;&amp;
  curl -fsSLO <span class="hljs-string">"https://github.com/kubernetes-sigs/krew/releases/latest/download/<span class="hljs-variable">${KREW}</span>.tar.gz"</span> &amp;&amp;
  tar zxvf <span class="hljs-string">"<span class="hljs-variable">${KREW}</span>.tar.gz"</span> &amp;&amp;
  ./<span class="hljs-string">"<span class="hljs-variable">${KREW}</span>"</span> install krew
)

<span class="hljs-comment"># Add krew to your PATH</span>
<span class="hljs-built_in">export</span> PATH=<span class="hljs-string">"<span class="hljs-variable">${KREW_ROOT:-<span class="hljs-variable">$HOME</span>/.krew}</span>/bin:<span class="hljs-variable">$PATH</span>"</span>
</code></pre>
<h4 id="heading-deploy-inspektor-gadget-to-your-cluster">Deploy Inspektor Gadget to your cluster:</h4>
<pre><code class="lang-bash">kubectl gadget deploy
</code></pre>
<p>This creates a <code>gadget</code> namespace and deploys the Inspektor Gadget daemon as a DaemonSet, ensuring each node in your cluster can run eBPF programs.</p>
<h4 id="heading-verify-the-deployment">Verify the deployment:</h4>
<pre><code class="lang-bash">kubectl get pods -n gadget
</code></pre>
<p>You should see one <code>gadget-*</code> pod per node, all in the <code>Running</code> state. If a pod is stuck in <code>Pending</code> or <code>CrashLoopBackOff</code>, check that your kernel meets the version requirements.</p>
<h4 id="heading-deploying-a-sample-application">Deploying a sample application</h4>
<p>To learn tracing effectively, we need an application that does something interesting. We'll deploy a simple microservices application with multiple components so you can see traces flowing across service boundaries.</p>
<p>Start by creating a namespace for our demo app:</p>
<pre><code class="lang-bash">kubectl create namespace demo-app
</code></pre>
<p>Then deploy a simple web application with a backend:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">apiVersion:</span> <span class="hljs-string">apps/v1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">Deployment</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">frontend</span>
  <span class="hljs-attr">namespace:</span> <span class="hljs-string">demo-app</span>
<span class="hljs-attr">spec:</span>
  <span class="hljs-attr">replicas:</span> <span class="hljs-number">1</span>
  <span class="hljs-attr">selector:</span>
    <span class="hljs-attr">matchLabels:</span>
      <span class="hljs-attr">app:</span> <span class="hljs-string">frontend</span>
  <span class="hljs-attr">template:</span>
    <span class="hljs-attr">metadata:</span>
      <span class="hljs-attr">labels:</span>
        <span class="hljs-attr">app:</span> <span class="hljs-string">frontend</span>
    <span class="hljs-attr">spec:</span>
      <span class="hljs-attr">containers:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">frontend</span>
        <span class="hljs-attr">image:</span> <span class="hljs-string">gcr.io/google-samples/microservices-demo/frontend:v0.8.0</span>
        <span class="hljs-attr">ports:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-attr">containerPort:</span> <span class="hljs-number">8080</span>
        <span class="hljs-attr">env:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">PORT</span>
          <span class="hljs-attr">value:</span> <span class="hljs-string">"8080"</span>
        <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">PRODUCT_CATALOG_SERVICE_ADDR</span>
          <span class="hljs-attr">value:</span> <span class="hljs-string">"productcatalog:3550"</span>
<span class="hljs-meta">---</span>
<span class="hljs-attr">apiVersion:</span> <span class="hljs-string">v1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">Service</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">frontend</span>
  <span class="hljs-attr">namespace:</span> <span class="hljs-string">demo-app</span>
<span class="hljs-attr">spec:</span>
  <span class="hljs-attr">type:</span> <span class="hljs-string">LoadBalancer</span>
  <span class="hljs-attr">selector:</span>
    <span class="hljs-attr">app:</span> <span class="hljs-string">frontend</span>
  <span class="hljs-attr">ports:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">port:</span> <span class="hljs-number">80</span>
    <span class="hljs-attr">targetPort:</span> <span class="hljs-number">8080</span>
<span class="hljs-meta">---</span>
<span class="hljs-attr">apiVersion:</span> <span class="hljs-string">apps/v1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">Deployment</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">productcatalog</span>
  <span class="hljs-attr">namespace:</span> <span class="hljs-string">demo-app</span>
<span class="hljs-attr">spec:</span>
  <span class="hljs-attr">replicas:</span> <span class="hljs-number">1</span>
  <span class="hljs-attr">selector:</span>
    <span class="hljs-attr">matchLabels:</span>
      <span class="hljs-attr">app:</span> <span class="hljs-string">productcatalog</span>
  <span class="hljs-attr">template:</span>
    <span class="hljs-attr">metadata:</span>
      <span class="hljs-attr">labels:</span>
        <span class="hljs-attr">app:</span> <span class="hljs-string">productcatalog</span>
    <span class="hljs-attr">spec:</span>
      <span class="hljs-attr">containers:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">server</span>
        <span class="hljs-attr">image:</span> <span class="hljs-string">gcr.io/google-samples/microservices-demo/productcatalogservice:v0.8.0</span>
        <span class="hljs-attr">ports:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-attr">containerPort:</span> <span class="hljs-number">3550</span>
        <span class="hljs-attr">env:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">PORT</span>
          <span class="hljs-attr">value:</span> <span class="hljs-string">"3550"</span>
<span class="hljs-meta">---</span>
<span class="hljs-attr">apiVersion:</span> <span class="hljs-string">v1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">Service</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">productcatalog</span>
  <span class="hljs-attr">namespace:</span> <span class="hljs-string">demo-app</span>
<span class="hljs-attr">spec:</span>
  <span class="hljs-attr">selector:</span>
    <span class="hljs-attr">app:</span> <span class="hljs-string">productcatalog</span>
  <span class="hljs-attr">ports:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">port:</span> <span class="hljs-number">3550</span>
    <span class="hljs-attr">targetPort:</span> <span class="hljs-number">3550</span>
</code></pre>
<p>Apply the configuration:</p>
<pre><code class="lang-bash">kubectl apply -f demo-app.yaml
</code></pre>
<p>And wait for pods to be ready:</p>
<pre><code class="lang-bash">kubectl <span class="hljs-built_in">wait</span> --<span class="hljs-keyword">for</span>=condition=ready pod -l app=frontend -n demo-app --timeout=300s
kubectl <span class="hljs-built_in">wait</span> --<span class="hljs-keyword">for</span>=condition=ready pod -l app=productcatalog -n demo-app --timeout=300s
</code></pre>
<p>Then just verify that everything is running:</p>
<pre><code class="lang-bash">kubectl get pods -n demo-app
</code></pre>
<p>You should see both <code>frontend</code> and <code>productcatalog</code> pods in the <code>Running</code> state.</p>
<p>Now you’ll need to get the frontend URL:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># For local clusters (minikube, kind, Docker Desktop)</span>
kubectl port-forward -n demo-app service/frontend 8080:80

<span class="hljs-comment"># Then access http://localhost:8080 in your browser</span>

<span class="hljs-comment"># For cloud clusters</span>
kubectl get service frontend -n demo-app
<span class="hljs-comment"># Look for the EXTERNAL-IP</span>
</code></pre>
<p>Visit the application in your browser to confirm it's working. You should see a simple e-commerce storefront. This application makes HTTP requests from the frontend to the product catalog service, which is perfect for tracing.</p>
<h2 id="heading-how-to-trace-your-first-request-hands-on-tutorial">How to Trace Your First Request: Hands-On Tutorial</h2>
<p>Now that everything is set up, let's capture our first trace and see eBPF observability in action.</p>
<h3 id="heading-generate-the-traffic-to-trace">Generate the Traffic to Trace</h3>
<p>First, we need some application activity to observe. We will generate a few requests for our demo application.</p>
<p>In one terminal, start the Traceloop gadget:</p>
<pre><code class="lang-bash">kubectl gadget traceloop -n demo-app
</code></pre>
<p>This command starts tracing HTTP request handling in the <code>demo-app</code> namespace. Inspektor Gadget monitors the kernel to capture the function calls and system events that occur while processing each request.  </p>
<p>In another terminal, generate some traffic:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># If using port-forward</span>
curl http://localhost:8080

<span class="hljs-comment"># If you have an external IP</span>
curl http://&lt;EXTERNAL-IP&gt;

<span class="hljs-comment"># Generate multiple requests</span>
<span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> {1..10}; <span class="hljs-keyword">do</span> curl http://localhost:8080; sleep 1; <span class="hljs-keyword">done</span>
```

<span class="hljs-comment">### Viewing Your First Trace</span>

Switch back to the terminal running the trace loop gadget. You should see output appearing as requests flow through your application. The output will look something like this:
```
NODE         NAMESPACE   POD              CONTAINER    PID    TYPE       COUNT  
minikube     demo-app    frontend-abc123  frontend     1234   loop       1      
minikube     demo-app    frontend-abc123  frontend     1234   loop       2
</code></pre>
<p>Each line shows a traced execution flow, with the count increasing as the same pattern is observed again.</p>
<p>We can make the output more interesting by filtering:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Stop the previous trace with Ctrl+C, then run:</span>
kubectl gadget traceloop -n demo-app --podname frontend
</code></pre>
<p>This narrows our observation to just the frontend pod, reducing noise and making patterns clearer.</p>
<h4 id="heading-understanding-what-youre-seeing">Understanding what you're seeing:</h4>
<p>Each column shows different information about your application:</p>
<ul>
<li><p><strong>NODE</strong>: Which Kubernetes node the traced event occurred on. In multi-node clusters, this helps you understand workload distribution and identify node-specific issues.</p>
</li>
<li><p><strong>NAMESPACE</strong>: The Kubernetes namespace. We filtered to <code>demo-app</code>, so you'll only see that namespace. In production, filtering by namespace is crucial for focusing on specific applications.</p>
</li>
<li><p><strong>POD</strong>: The specific pod where the event occurred. Each pod gets a unique name (like <code>frontend-abc123</code>), allowing you to distinguish between replicas of the same application.</p>
</li>
<li><p><strong>CONTAINER</strong>: Which container within the pod. Pods can have multiple containers (main application, sidecars, init containers), so this helps you pinpoint exactly where activity is happening.</p>
</li>
<li><p><strong>PID</strong>: The process ID inside the container. This is the actual Linux process that made the syscalls eBPF observed. Multiple PIDs might appear if your application uses multiple processes or threads.</p>
</li>
<li><p><strong>TYPE</strong>: The type of event traced. For Traceloop, this identifies kernel-level patterns detected during request processing.</p>
</li>
<li><p><strong>COUNT</strong>: How many times this pattern has been observed. A rapidly incrementing count indicates high request volume.</p>
</li>
</ul>
<h4 id="heading-what-this-tells-you-about-your-application">What this tells you about your application:</h4>
<p>Even from this simple output, you can derive insights. If you see events appearing for the <code>frontend</code> pod but not the <code>productcatalog</code> pod, it might indicate that requests aren't making it to the backend. This is a potential configuration issue. If the <code>COUNT</code> increases rapidly for one pod but not others, you know which replica is receiving traffic, useful for debugging load balancing issues.</p>
<p>The real power becomes clear when you correlate these kernel-level observations with what you know about your application. When you made 10 curl requests, you should see corresponding activity in the trace output. This direct relationship between application behavior and kernel observations is the foundation of eBPF observability.</p>
<h2 id="heading-how-to-interpret-traces">How to Interpret Traces</h2>
<p>Understanding raw trace output is valuable, but interpreting what it means for your application's health and performance is where the real skill lies.</p>
<h3 id="heading-trace-anatomy-spans-timing-and-request-flow">Trace Anatomy: Spans, Timing, and Request Flow</h3>
<p>A trace represents a single request's journey through your system. When you curl the frontend, that generates one trace. A span represents a single operation within that trace like "frontend handles request," "frontend calls product catalog," "product catalog queries data," and "frontend returns response." Each span has timing information: when it started, when it ended, and therefore how long it took.</p>
<p>In traditional distributed tracing with OpenTelemetry or Jaeger, you'd explicitly create these spans in your application code. With eBPF, the tool infers spans from syscall patterns. When eBPF sees your frontend process call <code>connect()</code> to the product catalog's IP, followed by <code>send()</code> and <code>recv()</code>, it understands that's a span representing an HTTP request to the backend service.</p>
<p>The request flow is the sequence of spans showing how your request moved through services. In our demo app,</p>
<ol>
<li><p>The user request arrives at the frontend,</p>
</li>
<li><p>the frontend connects to the product catalog,</p>
</li>
<li><p>the product catalog processes the request,</p>
</li>
<li><p>the product catalog returns the data, the frontend renders the page,</p>
</li>
<li><p>and finally, the response is sent to user.</p>
</li>
</ol>
<h3 id="heading-how-to-follow-requests-across-services">How to Follow Requests Across Services</h3>
<p>Let's trace a request across service boundaries to see this flow in action.</p>
<p>First, we’ll start a more detailed trace:</p>
<pre><code class="lang-bash">kubectl gadget trace_tcp -n demo-app
</code></pre>
<p>The trace_tcp gadget shows network connections, giving us visibility into service-to-service communication.</p>
<p>Next, generate a request:</p>
<pre><code class="lang-bash">curl http://localhost:8080
</code></pre>
<p>In the trace output, look for connection patterns:</p>
<p>You should see the frontend pod establishing a TCP connection to the product catalog service. The trace will show the source (frontend) and destination (product catalog) IPs and ports, along with timing information.</p>
<p>This is how eBPF lets you follow requests: by observing the network syscalls that implement service communication. You don't need a service mesh or instrumentation libraries, the kernel sees all network activity and eBPF captures it.</p>
<h4 id="heading-understanding-the-flow">Understanding the flow:</h4>
<ol>
<li><p>Your curl command triggers a TCP connection to the frontend pod's IP on port 8080</p>
</li>
<li><p>The frontend processes the request and opens a TCP connection to the product catalog's IP on port 3550</p>
</li>
<li><p>Data flows back and forth (you'll see send/receive events)</p>
</li>
<li><p>Connections close when requests complete</p>
</li>
</ol>
<p>Each step is visible to eBPF because each step requires syscalls that the kernel handles.</p>
<h3 id="heading-how-to-identify-bottlenecks-and-errors">How to Identify Bottlenecks and Errors</h3>
<p>We can also use tracing to identify performance issues.</p>
<p>First, let’s start by simulating a slow backend:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Create a deliberately slow endpoint by modifying our deployment</span>
kubectl scale deployment productcatalog -n demo-app --replicas=0

<span class="hljs-comment"># Wait a moment, then scale back up</span>
kubectl scale deployment productcatalog -n demo-app --replicas=1
</code></pre>
<p>While the product catalog is down, generate some requests:</p>
<pre><code class="lang-bash"><span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> {1..5}; <span class="hljs-keyword">do</span> curl http://localhost:8080; <span class="hljs-keyword">done</span>
</code></pre>
<p>You should see connection attempts from the frontend to the product catalog, but if the service is unavailable, you'll see different patterns, possibly connection timeouts or connection refused errors, depending on the exact timing.</p>
<p>What bottlenecks look like in traces:</p>
<ul>
<li><p><strong>Long spans</strong>: A span that takes significantly longer than others indicates a bottleneck. In trace loop output, you might see gaps between events or notice certain operations taking longer.</p>
</li>
<li><p><strong>Retries</strong>: Repeated connection attempts to the same destination suggest a failing or slow service.</p>
</li>
<li><p><strong>Error patterns</strong>: Connection failures, timeouts, or unusual syscall sequences indicate problems.</p>
</li>
</ul>
<p>The best skill to have is pattern recognition. A typical, healthy request flow has a rhythm, and events occur in predictable sequences with consistent timing. When something breaks, the rhythm changes. Requests take longer, errors appear, or expected events don't occur at all.</p>
<h2 id="heading-real-world-debugging-scenarios">Real-World Debugging Scenarios</h2>
<p>Now let's go through three realistic scenarios where eBPF helps:</p>
<h3 id="heading-scenario-1-finding-a-slow-endpoint">Scenario 1: Finding a Slow Endpoint</h3>
<p><strong>The problem:</strong> Users report that the product catalog page sometimes loads very slowly, but metrics show normal average latency.</p>
<p>Let’s use Traceloop to investigate:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Start tracing with timing information</span>
kubectl gadget traceloop -n demo-app --podname frontend
</code></pre>
<p>We’ll generate some mixed traffic:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Some requests to the homepage (fast)</span>
curl http://localhost:8080

<span class="hljs-comment"># Some requests to the product catalog (potentially slow)</span>
curl http://localhost:8080/products
</code></pre>
<p>In the trace output, compare the <code>COUNT</code> increments for different request patterns. If certain patterns show significantly more loop iterations or longer gaps between events, that indicates those requests are doing more work, possibly hitting a slow endpoint.</p>
<h4 id="heading-the-diagnosis">The diagnosis:</h4>
<p>You might notice that requests to <code>/products</code> cause the frontend to make multiple calls to the product catalog service (visible with <code>kubectl gadget trace_tcp</code>), while homepage requests don't. This explains why the product page is slow: it's making synchronous calls to a backend service, and if that service is slow or the network is congested, users feel the delay.</p>
<h4 id="heading-the-fix">The fix:</h4>
<p>You might implement caching, make the backend calls asynchronous, or optimize the product catalog service itself. The key is that eBPF helped you identify which specific code path was slow without adding instrumentation to your application.</p>
<h3 id="heading-scenario-2-tracking-down-failed-requests">Scenario 2: Tracking Down Failed Requests</h3>
<p><strong>The problem:</strong> Your monitoring shows a 5% error rate, but application logs don't show any errors. Where are the failures happening?</p>
<p>Now let’s use eBPF to investigate:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Trace network connections to see connection failures</span>
kubectl gadget trace_tcp -n demo-app
</code></pre>
<p>We’ll simulate intermittent failures:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Create a failing scenario by temporarily breaking service connectivity</span>
kubectl delete service productcatalog -n demo-app

<span class="hljs-comment"># Generate requests</span>
<span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> {1..10}; <span class="hljs-keyword">do</span> curl http://localhost:8080; sleep 1; <span class="hljs-keyword">done</span>

<span class="hljs-comment"># Restore the service</span>
kubectl apply -f demo-app.yaml
</code></pre>
<p>In the TCP trace, you'll see connection attempts from the frontend to the product catalog that fail or time out. The trace will show the source, destination, and what happened (connection refused, timeout, and so on).</p>
<h4 id="heading-the-diagnosis-1">The diagnosis:</h4>
<p>The failures are happening at the network level, the frontend can't reach the product catalog. This might be due to network policy issues, service mesh misconfiguration, or DNS problems. Traditional application logs might not capture this because the application never receives a response to log, and the connection fails before the application layer even gets involved.</p>
<h4 id="heading-why-ebpf-finds-this-when-logs-dont">Why eBPF finds this when logs don't:</h4>
<p>Your application logs what it experiences. If a connection fails at the TCP level, your application might just see "connection refused" and retry without detailed logging.</p>
<p>eBPF sees the actual syscalls and network events, giving you visibility into what's happening beneath your application layer.</p>
<h3 id="heading-scenario-3-understanding-service-dependencies">Scenario 3: Understanding Service Dependencies</h3>
<p><strong>The problem:</strong> You're not sure which services depend on each other, and you want to understand the actual runtime dependencies before making changes.</p>
<p>We’ll use eBPF to map dependencies:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Trace all TCP connections to see who talks to whom</span>
kubectl gadget trace_tcp -n demo-app
</code></pre>
<p>And then generate normal traffic:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Make various requests to exercise different code paths</span>
curl http://localhost:8080
curl http://localhost:8080/products
curl http://localhost:8080/cart
</code></pre>
<p>The trace output shows source and destination for every connection. Build a mental (or actual) map of which pods connect to which services.</p>
<h4 id="heading-the-discovery">The discovery:</h4>
<p>You'll see that the frontend pod connects to the product catalog service, but you might also discover unexpected dependencies. Perhaps the frontend also makes calls to a Redis cache, an authentication service, or external APIs. These runtime dependencies might not be documented or might differ from what architectural diagrams show.</p>
<h4 id="heading-why-this-matters">Why this matters:</h4>
<p>Before deploying a change to the product catalog service, you now know exactly which services will be affected. Before implementing a network policy, you know which connections to allow. Before decomposing a monolith, you understand the actual communication patterns.</p>
<p>This is observability-driven architecture understanding: letting the system show you how it actually works, not how you think it works.</p>
<h2 id="heading-advanced-tracing-insights">Advanced Tracing Insights</h2>
<p>Once you're comfortable with basic request tracing, Inspektor Gadget offers deeper observability capabilities that reveal even more about your system's behavior.</p>
<h3 id="heading-syscall-level-observation">Syscall-Level Observation</h3>
<p>The traceloop and trace_tcp gadgets give you application-level insights, but sometimes you need to go deeper. The trace_exec gadget shows you every process execution in your containers.</p>
<p>First, let’s monitor process execution:</p>
<pre><code class="lang-bash">kubectl gadget trace_exec -n demo-app
</code></pre>
<p>And generate activity:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Exec into a pod and run commands</span>
kubectl <span class="hljs-built_in">exec</span> -it -n demo-app deployment/frontend -- /bin/sh
ls -la
ps aux
<span class="hljs-built_in">exit</span>
</code></pre>
<p>Every command you run inside the container appears in the trace: <code>/bin/sh</code>, <code>ls</code>, <code>ps</code>, and anything else. This helps you understand what's running in your containers, detect suspicious activity, or debug initialization issues.</p>
<p>In production scenarios, this helps you answer questions like: Is my application spawning unexpected subprocesses? Are there security issues like someone running <code>curl</code> to download malicious scripts? Is my <code>init</code> script actually running the commands I think it is?</p>
<h3 id="heading-network-tracing-insights">Network Tracing Insights</h3>
<p>Beyond TCP connections, you can trace DNS queries, which often reveal surprising things about your application's behavior.</p>
<p>Run <code>trace_dns</code>:</p>
<pre><code class="lang-bash">kubectl gadget trace_dns -n demo-app
</code></pre>
<p>Generate requests:</p>
<pre><code class="lang-bash">curl http://localhost:8080
</code></pre>
<p>You'll see every DNS query your application makes: resolving service names, checking for external APIs, perhaps even unexpected queries that indicate misconfiguration or dependencies you didn't know about.</p>
<p>Common insights from DNS tracing include discovering that your application is using external dependencies you didn't document, finding DNS resolution failures that cause intermittent errors, or identifying excessive DNS queries that could be cached.</p>
<h3 id="heading-combining-ebpf-data-with-logs-and-metrics">Combining eBPF Data with Logs and Metrics</h3>
<p>eBPF observability delivers the best results when combined with traditional observability signals. To combine them effectively:</p>
<ul>
<li><p>Use metrics for high-level health monitoring, alerting on anomalies, tracking trends over time, and dashboard visualization.</p>
</li>
<li><p>Use logs for application-specific context, business logic details, error messages with stack traces, and debugging application code.</p>
</li>
<li><p>Use eBPF traces for understanding request flows, identifying where time is spent, discovering runtime dependencies, and debugging issues that don't appear in logs.</p>
</li>
</ul>
<h4 id="heading-a-practical-workflow">A practical workflow:</h4>
<p>Your metrics alert you that latency increased. You check logs but don't see errors, requests are succeeding, just slowly. You use eBPF tracing to identify that requests are spending extra time in network I/O to a particular backend service. Now you check that service's metrics and logs, and discover it's under heavy load. The eBPF trace gave you the clue that logs and metrics alone couldn't provide.</p>
<p>This approach to observability, using the right tool for each question, is how experienced engineers debug complex systems efficiently.</p>
<h3 id="heading-what-ebpf-can-and-cant-see"><strong>What eBPF Can and Can't See</strong></h3>
<p>eBPF excels at:</p>
<ul>
<li><p>Network traffic (requests, responses, latency)</p>
</li>
<li><p>System calls (file I/O, process creation, memory allocation)</p>
</li>
<li><p>Kernel functions (scheduling, locking, resource usage)</p>
</li>
<li><p>Function calls in binaries (with uprobes)</p>
</li>
</ul>
<p>But keep in mind that eBPF has limitations:</p>
<ul>
<li><p>Cannot decrypt encrypted payloads (unless hooking SSL libraries before encryption)</p>
</li>
<li><p>Doesn't automatically understand application logic</p>
</li>
<li><p>Captures low-level events but may need context for high-level semantics</p>
</li>
</ul>
<p>That's why eBPF complements traditional observability rather than replacing it entirely. It gives you infrastructure-level visibility with no code changes and universal coverage. Traditional APM provides application-level context, business metrics, and custom instrumentation. Together, they give you complete observability across your entire stack.</p>
<h2 id="heading-best-practices-and-production-considerations">Best Practices and Production Considerations</h2>
<p>Before using eBPF tracing in production, there are important considerations around performance, security, and operational practices.</p>
<h3 id="heading-performance-impact">Performance Impact</h3>
<p>eBPF's reputation for low overhead is well-deserved, but "low" isn't "zero."</p>
<p>Most eBPF tracing tools add 2-5% CPU overhead and negligible memory overhead. The exact number depends on event frequency, tracing a service that handles 10,000 requests per second will have more overhead than one handling 10 per second.</p>
<p>Measuring the impact:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Before enabling tracing, check baseline resource usage</span>
kubectl top pods -n demo-app

<span class="hljs-comment"># Enable tracing</span>
kubectl gadget traceloop -n demo-app

<span class="hljs-comment"># Check resource usage again</span>
kubectl top pods -n demo-app
</code></pre>
<p>You should see a small increase in CPU usage in the pods where tracing is active. This is the cost of the eBPF programs running in the kernel and processing events.</p>
<h4 id="heading-production-best-practices">Production best practices:</h4>
<p>Use targeted tracing rather than tracing everything everywhere. Trace specific namespaces, pods, or individual containers when investigating issues. For high-volume services, reduce overhead by applying filters, aggregation, or sampling where supported by the tracing tool.</p>
<p>Stop tracing when you’re done investigating. Unlike metrics collection, which typically runs continuously, eBPF-based tracing is best used as an on-demand diagnostic tool to capture detailed insights during active debugging.</p>
<h4 id="heading-when-overhead-matters">When overhead matters:</h4>
<p>If you're running latency-sensitive applications (like high-frequency trading systems or real-time communications), even 2-5% overhead might be unacceptable. In these cases, use eBPF tracing in pre-production environments to identify issues, or enable it temporarily in production only when actively debugging.</p>
<h3 id="heading-security-considerations">Security Considerations</h3>
<p>eBPF is powerful, which means it requires elevated privileges. Understanding the security implications is crucial.</p>
<h4 id="heading-what-ebpf-can-access">What eBPF can access:</h4>
<p>eBPF programs can observe all syscalls, network traffic, and process execution in the kernel. This includes potentially sensitive data like connection details, file paths, and process arguments. While eBPF programs run in a sandbox and can't modify data or crash the kernel, they can read information that might be sensitive.</p>
<h4 id="heading-privilege-requirements">Privilege requirements:</h4>
<p>Loading eBPF programs requires <code>CAP_SYS_ADMIN</code> or <code>CAP_BPF</code> capabilities (on newer kernels). This is a privileged operation, only trusted users should have this access. The Inspektor Gadget DaemonSet runs with these privileges, so protect access to it accordingly.</p>
<h4 id="heading-best-practices">Best practices:</h4>
<p>Implement RBAC (Role-Based Access Control) to restrict who can run gadgets. Not every developer needs the ability to trace production systems.</p>
<p>Also, be mindful of what data you're collecting, if your traces might contain sensitive information (like authentication tokens in HTTP headers), restrict access to trace data.</p>
<p>Lastly, consider using admission controllers to prevent unauthorized eBPF program loading. Audit eBPF usage in production environments to track who ran which gadgets when.</p>
<h4 id="heading-network-policies">Network policies:</h4>
<p>Inspektor Gadget's DaemonSet needs to communicate with the API server and between its components. Ensure your network policies allow this communication while still maintaining appropriate segmentation.</p>
<h3 id="heading-when-to-use-ebpf-tracing-vs-traditional-apm">When to Use eBPF Tracing vs. Traditional APM</h3>
<p>eBPF tracing and traditional APM tools like New Relic, Datadog, or Dynatrace serve different purposes. Understanding when to use each helps you build an effective observability strategy.</p>
<p>Use eBPF tracing when:</p>
<ul>
<li><p>You can't modify application code (third-party applications, legacy systems, compiled binaries)</p>
</li>
<li><p>You need infrastructure-level visibility (network, syscalls, kernel behavior)</p>
</li>
<li><p>You're debugging issues that span service boundaries but don't show up in application logs</p>
</li>
<li><p>You want zero instrumentation overhead during normal operation (run tracing only when needed)</p>
</li>
<li><p>You need to understand what's actually happening versus what the application reports</p>
</li>
</ul>
<p>Use traditional APM when:</p>
<ul>
<li><p>You need business-context metrics (user IDs, transaction types, business-specific data)</p>
</li>
<li><p>You want automatic instrumentation with minimal setup for supported frameworks</p>
</li>
<li><p>You need long-term storage and analysis of all traces (eBPF tracing is often used for real-time investigation)</p>
</li>
<li><p>You want pre-built dashboards and alerting for common application patterns</p>
</li>
<li><p>You need application code-level visibility (stack traces, variable values, function calls)</p>
</li>
</ul>
<h3 id="heading-the-ideal-approach-use-both">The Ideal Approach: Use Both</h3>
<p>Many teams run traditional APM for continuous monitoring and use eBPF tracing for targeted investigation when APM data isn't sufficient. For example, your APM shows that a service is slow but doesn't explain why. You enable eBPF tracing on that service to understand what's happening at the kernel level, network delays, excessive syscalls, unexpected dependencies, and find the root cause.</p>
<p>This complementary approach gives you both the continuous visibility of APM and the deep diagnostic power of eBPF without the overhead of running both at maximum depth all the time.</p>
<h2 id="heading-next-steps-and-resources">Next Steps and Resources</h2>
<p>If you got this far, thanks for reading! Now that you have learned the fundamentals of eBPF observability, and hands-on tracing with Inspektor Gadget, you can continue your journey by:</p>
<h3 id="heading-exploring-other-ebpf-tools">Exploring Other eBPF Tools</h3>
<p>Now that you understand eBPF concepts through traceloop, exploring other tools will be much easier.</p>
<h4 id="heading-try-other-inspektor-gadget-gadgets">Try other Inspektor Gadget gadgets:</h4>
<pre><code class="lang-bash"><span class="hljs-comment"># See all available gadgets</span>
kubectl gadget --<span class="hljs-built_in">help</span>

<span class="hljs-comment"># Some useful ones to explore:</span>
kubectl gadget trace_open -n demo-app     <span class="hljs-comment"># File I/O tracing</span>
kubectl gadget trace_bind -n demo-app     <span class="hljs-comment"># Port binding events</span>
kubectl gadget profile cpu -n demo-app    <span class="hljs-comment"># CPU profiling</span>
kubectl gadget snapshot process -n demo-app  <span class="hljs-comment"># Process listing</span>
</code></pre>
<p>Each gadget teaches you something different about system behavior and gives you another diagnostic tool in your toolkit.</p>
<h3 id="heading-experiment-with-other-ebpf-platforms">Experiment with other eBPF platforms:</h3>
<p>If you're interested in broader observability platforms, try Pixie for its auto-instrumentation and rich UI. Install Cilium with Hubble if you're focused on network observability and want to understand service mesh behavior. Explore Tetragon if security observability interests you, seeing what processes are executing and what files they're accessing.</p>
<p>The concepts transfer directly: all these tools attach eBPF programs to kernel hooks, collect event data, and present it in different ways. Your understanding of syscalls, traces, and kernel-level observation applies universally.</p>
<h3 id="heading-connect-to-the-cncf-observability-ecosystem">Connect to the CNCF Observability Ecosystem</h3>
<p>eBPF observability tools don't exist in isolation. They're part of the broader Cloud Native Computing Foundation ecosystem.</p>
<h4 id="heading-opentelemetry-integration">OpenTelemetry integration:</h4>
<p>Many eBPF tools can export data in OpenTelemetry format, allowing you to combine kernel-level traces with application-level traces in a unified observability backend. This gives you the complete picture: eBPF shows you infrastructure behavior while OpenTelemetry shows you application context.</p>
<h4 id="heading-prometheus-and-grafana">Prometheus and Grafana:</h4>
<p>eBPF-derived metrics can be exposed as Prometheus metrics and visualized in Grafana alongside your application metrics. This unified dashboard approach helps you correlate infrastructure and application behavior.</p>
<h4 id="heading-service-mesh-integration">Service mesh integration:</h4>
<p>If you're using Istio, Linkerd, or other service meshes, eBPF tools like Cilium Hubble can provide deeper visibility into service-to-service communication than the mesh alone provides. The mesh handles traffic management while eBPF gives you kernel-level visibility.</p>
<h4 id="heading-jaeger-and-zipkin">Jaeger and Zipkin:</h4>
<p>For organizations using distributed tracing backends, eBPF traces can be exported to these systems, enriching your trace data with infrastructure-level spans that application instrumentation misses.</p>
<h3 id="heading-community-resources-and-learning-paths">Community Resources and Learning Paths</h3>
<p>The eBPF community is vibrant and welcoming. You can continue learning from the resources below.</p>
<p><strong>Official documentation and blog:</strong></p>
<ul>
<li><p><a target="_blank" href="http://eBPF.io">eBPF.io</a>: The central hub for eBPF documentation, tutorials, and project listings</p>
</li>
<li><p><a target="_blank" href="https://inspektor-gadget.io/docs/latest/">Inspektor Gadget docs</a>: Comprehensive guides for all gadgets and use cases</p>
</li>
<li><p><a target="_blank" href="https://docs.cilium.io/en/stable/index.html">Cilium documentation</a>: Deep dives into eBPF networking</p>
</li>
<li><p><a target="_blank" href="https://www.cncf.io/blog/2025/01/27/what-is-observability-2-0/">CNCF Blog — “What is Observability 2.0?</a>: A quick overview of how modern observability moves beyond traditional tools by unifying metrics, logs, and traces for real-time insight in cloud-native systems.</p>
</li>
</ul>
<p><strong>Learning resources:</strong></p>
<ul>
<li><p><a target="_blank" href="https://cilium.isovalent.com/hubfs/Learning-eBPF%20-%20Full%20book.pdf">Learning eBPF by Liz Rice</a>: Comprehensive book covering eBPF fundamentals</p>
</li>
<li><p><a target="_blank" href="https://ebpf.io/summit-2025/">eBPF Summit</a>: Annual conference with talks from eBPF creators and users</p>
</li>
<li><p><a target="_blank" href="https://www.cncf.io/online-programs/cncf-on-demand-webinar-how-to-start-building-a-self-service-infrastructure-platform-on-kubernetes/">CNCF webinars</a>: Regular sessions on observability topics</p>
</li>
<li><p><a target="_blank" href="https://www.kubernetes.dev/community/community-groups/">Kubernetes observability SIGs</a>: Community discussions and projects</p>
</li>
</ul>
<p>To make this tutorial easy to follow and experiment with, I have included all Kubernetes manifests, demo applications, and eBPF tracing commands in this <a target="_blank" href="https://github.com/Emidowojo/ebpf-k8s-tracing-tutorial">repository</a>. You can also connect with me on <a target="_blank" href="https://www.linkedin.com/in/emidowojo/">LinkedIn</a> if you’d like to stay in touch.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ A Beginner's Guide to Observability in Cloud Native Applications ]]>
                </title>
                <description>
                    <![CDATA[ If you're new to cloud native technologies, you may have heard the term 'observability' before. But what exactly does it mean? Is it simply the ability to observe? And if so, what are we observing and why? I had the same questions when I started lear... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/observability-in-cloud-native-applications/</link>
                <guid isPermaLink="false">67e2d66c64d44185d5a6d406</guid>
                
                    <category>
                        <![CDATA[ otlp resource attributes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloud native applications ]]>
                    </category>
                
                    <category>
                        <![CDATA[ observability ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #prometheus ]]>
                    </category>
                
                    <category>
                        <![CDATA[ OpenTelemetry ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Otel ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Victoria Nduka ]]>
                </dc:creator>
                <pubDate>Tue, 25 Mar 2025 16:14:36 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1742917070693/fa372981-fb20-4230-bd9f-43b7255b8ced.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you're new to cloud native technologies, you may have heard the term 'observability' before. But what exactly does it mean? Is it simply the ability to observe? And if so, what are we observing and why?</p>
<p>I had the same questions when I started learning about cloud-native technologies. In this article, I'll share my understanding of core observability concepts, introduce essential observability tools, and share insights from a related project I’m working on.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-my-introduction-to-cloud-native-technologies">My Introduction to Cloud Native Technologies</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-is-observability">What is Observability?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-types-of-observability-data">Types of Observability Data</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-metrics">1. Metrics</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-logs">2. Logs</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-traces">3. Traces</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-observability-tools">Observability Tools</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-prometheus">Prometheus</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-opentelemetry">OpenTelemetry</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-what-are-otlp-resource-attributes">What are OTLP Resource Attributes?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-importance-of-otlp-resource-attributes">Importance of OTLP Resource Attributes</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-my-project-work-fits-into-all-this">How My Project Work Fits into All This</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-additional-resources">Additional Resources</a></p>
</li>
</ul>
<h2 id="heading-my-introduction-to-cloud-native-technologies">My Introduction to Cloud Native Technologies</h2>
<p>I recently got selected as a mentee for the Linux Foundation Mentorship to work on the <a target="_blank" href="https://mentorship.lfx.linuxfoundation.org/project/36e3f336-ce78-4074-b833-012015eb59be">CNCF - Prometheus project</a>. The project is UX-focused, and for the next few months, I'll be working with my mentors to understand how users expect to use OpenTelemetry Line Protocol (OTLP) Resource Attributes in Prometheus.</p>
<p>That's quite a mouthful, I know. I was overwhelmed at first, and honestly, I’m still figuring it out. This is my third week, and although I still have a lot to learn—given that I had no knowledge of cloud native technologies when I applied for this internship—I've already learned quite a bit.</p>
<p>As I often do, I intend to document what I learn through articles to help reinforce concepts in my memory and serve as a resource for other newcomers who may find themselves grappling with these technical terms in the future. You know what they say: you can't say you've understood something until you're able to explain it to someone else who's also new to the topic.</p>
<h2 id="heading-what-is-observability">What is Observability?</h2>
<p>First, I had to learn what the unfamiliar terms meant—and there were a lot of them flying around. OpenTelemetry. Prometheus. Resource attributes. I’ve come to understand that these terms fall under one umbrella: Observability. Let's start there.</p>
<p>Let’s use a food delivery app to illustrate. When someone orders food, a lot happens behind the scenes:</p>
<ul>
<li><p>The app connects to different services (restaurants, payments, delivery)</p>
</li>
<li><p>Data flows between different systems to process the order, assign a driver, and track delivery</p>
</li>
</ul>
<p>Engineers need to monitor all the processes to ensure everything works smoothly. Are orders taking too long to process? Is the payment system failing? Does the app suddenly crash under load? Which part of the system is causing delays?</p>
<p>To answer these questions, engineers <strong>instrument</strong> their code. This means that they configure it to send back real-time data about the state, performance, and behavior of the application. This practice of understanding what's happening inside a complex system based on the data it generates is known as <strong>Observability</strong>.</p>
<p>You can see the process illustrated in the image below:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1742335445056/5fe7bb0b-bdf9-4f52-a2c1-7f2977411c6c.png" alt="A flowchart diagram titled &quot;Visual flow of observability data&quot; showing how data moves through a food delivery application system. The flow starts with a User who orders food from a Food App. The Food App connects to three services (Restaurant, Payment, and Delivery). All these components send data to OpenTelemetry, which collects three types of data: Metrics, Logs, and Traces. OpenTelemetry then forwards only the Metrics data to Prometheus, which stores metrics." class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>In the above flowchart diagram, you can see how data might move through a food delivery application system. The flow starts with a User who orders food from a Food App. The Food App connects to three services (Restaurant, Payment, and Delivery). All these components send data to OpenTelemetry, which collects three types of data: Metrics, Logs, and Traces. OpenTelemetry then forwards only the Metrics data to Prometheus, which stores metrics.</p>
<h2 id="heading-types-of-observability-data">Types of Observability Data</h2>
<p>There are three key types of data that systems generate for observability:</p>
<h3 id="heading-1-metrics"><strong>1. Metrics</strong></h3>
<p>Metrics are numerical measurements collected over time that represent the state or performance of your system. Examples in a food delivery app would be the number of orders processed per minute, average order processing time in milliseconds, number of active users or delivery drivers, and so on.</p>
<h3 id="heading-2-logs"><strong>2. Logs</strong></h3>
<p>Logs are text-based records of discrete events that occur within your application. Logs for our food delivery app would look something like this:</p>
<pre><code class="lang-http"><span class="hljs-attribute">ERROR</span>: Payment failed for order #12345 - Credit card declined
<span class="hljs-attribute">INFO</span>: Driver #789 assigned to order #12345
</code></pre>
<h3 id="heading-3-traces"><strong>3. Traces</strong></h3>
<p>Traces track the entire lifecycle of a request as it moves through different services in a system. They help engineers see how different components interact and identify bottlenecks in complex, distributed systems.</p>
<p>For example, in our food delivery app, a single order request might go through the following steps:<br><code>User places an order</code> → <code>Request sent to restaurant system</code> → <code>Payment processor verifies payment</code> → <code>Delivery system assigns a driver</code> → <code>User receives confirmation</code>.</p>
<p>Each step in this journey is recorded as part of a trace. This helps engineers pinpoint where delays occur and optimize the system for better performance.</p>
<p>Observability relies on metrics, logs, and traces working together to provide full system visibility. Metrics tell you something is wrong (“Error rate increased by 5%”). Logs tell you why it happened (“Payment failed due to invalid card details”). Traces show exactly where it happened (“Delay in restaurant service response”).</p>
<h2 id="heading-observability-tools"><strong>Observability Tools</strong></h2>
<p>Observability tools give you visibility into what’s going on within your application. There are a lot of them, but for the purpose of this article, we’ll talk about two: Prometheus and OpenTelemetry. </p>
<h3 id="heading-prometheus"><strong>Prometheus</strong></h3>
<p><a target="_blank" href="https://prometheus.io/">Prometheus</a> is an open-source monitoring and alerting toolkit. It does two things:</p>
<ul>
<li><p>Collects data from applications, specifically metrics (remember the data types we talked about earlier)</p>
</li>
<li><p>and stores them in a time-series database.</p>
</li>
</ul>
<p>A time-series database is a database specifically designed to handle measurements or events that occur over time.</p>
<p>Prometheus uses what's called a <strong>pull-based model</strong> to collect metrics from applications. Pull-based means Prometheus actively requests (pulls) data from services at regular intervals. Think of it like refreshing a webpage to get the latest content.</p>
<h3 id="heading-opentelemetry"><strong>OpenTelemetry</strong></h3>
<p><a target="_blank" href="https://opentelemetry.io/">OpenTelemetry (OTel)</a> collects, processes, and exports observability data. Unlike Prometheus, which mainly focuses on metrics, OpenTelemetry provides a standardized way to instrument applications for all three types of observability data: logs, metrics, and traces.</p>
<p>OpenTelemetry is designed to be vendor-agnostic. This means you can instrument your applications once with OpenTelemetry and then send that telemetry data to any supported observability backend, which could be an open-source solution like Jaeger or Prometheus, or commercial platforms like Datadog, New Relic, Dynatrace, or Honeycomb.</p>
<p>So, for example, you can use OpenTelemetry to instrument your application – and then Prometheus can pull metrics from OpenTelemetry while other tools handle logs and traces.</p>
<h2 id="heading-what-are-otlp-resource-attributes"><strong>What are OTLP Resource Attributes?</strong></h2>
<p>When OpenTelemetry collects data from applications, it does more than just gather raw telemetry data. It also provides context about that data. This context comes in the form of <strong>resource attributes</strong>, which describe where the data came from and what it relates to.</p>
<p>The 'resource' is the component (or entity) producing the data, while the 'attributes' are specific details about that resource.</p>
<p>Resource attributes are structured as pairs of information:</p>
<ul>
<li><p>The "key" is the name or identifier of the attribute (like <code>service.name</code> or <code>host.id</code>)</p>
</li>
<li><p>The "value" is the specific information for that attribute (like <code>payment-service</code> or <code>server-123</code>)</p>
</li>
</ul>
<p>Together, these key-value pairs identify and describe the specific component that's generating the observability data.</p>
<p>For example, if a payment processing service is sending metrics about transaction times, the resource attributes might include:</p>
<ul>
<li><p><code>service.name: "payment-service"</code></p>
</li>
<li><p><code>service.version: "1.2.3"</code></p>
</li>
<li><p><code>deployment.environment: "production"</code></p>
</li>
</ul>
<p>These attributes tell you exactly which service, which version, and in which environment the data is coming from, providing context for interpreting the metrics, logs, or traces.</p>
<p>Resource attributes are not arbitrary. OpenTelemetry provides a standardized set of attribute names and formats that everyone should follow, similar to having an agreed-upon language for describing services and their properties.</p>
<p>For example, OpenTelemetry specifies that you should use <code>service.name</code> (not <code>app_name</code> or <code>service_id</code>) to identify your service. They've created these standardized naming conventions (called <a target="_blank" href="https://opentelemetry.io/docs/concepts/semantic-conventions/">semantic conventions</a>) so that:</p>
<ol>
<li><p>All tools in the ecosystem can understand the same attributes</p>
</li>
<li><p>Engineers across different companies use consistent terminology</p>
</li>
<li><p>Observability data can be easily shared between different systems</p>
</li>
</ol>
<p>You can still create your own custom attributes when you need something specific (like <code>payment.provider</code> for a payment service), but using the standard attributes whenever possible means your telemetry data will work better with existing tools and be more easily understood by other engineers.</p>
<h2 id="heading-importance-of-otlp-resource-attributes">Importance of OTLP Resource Attributes</h2>
<p>Let’s say engineers want to monitor how long food deliveries take and whether there are delays in specific locations. Without resource attributes, OpenTelemetry might simply collect and report this metric like this:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">delivery_time_seconds:</span> <span class="hljs-number">1800</span>
</code></pre>
<p>This tells us that a delivery took 1,800 seconds, or 30 minutes, but nothing else. That’s useful, but it lacks context. Where did this happen? Which service handled it? If there was a delay in delivery and engineers wanted to investigate the cause, this alone would not help.</p>
<p>With OpenTelemetry’s resource attributes, the metric becomes more meaningful:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">delivery_time_seconds:</span> <span class="hljs-number">1800</span>
<span class="hljs-attr">resource:</span>
  <span class="hljs-attr">service.name:</span> <span class="hljs-string">"delivery-service"</span>
  <span class="hljs-attr">service.instance.id:</span> <span class="hljs-string">"instance-456"</span>
  <span class="hljs-attr">cloud.region:</span> <span class="hljs-string">"ng-west-2"</span>
  <span class="hljs-attr">deployment.environment:</span> <span class="hljs-string">"production"</span>
  <span class="hljs-attr">customer.city:</span> <span class="hljs-string">"Lagos"</span>
  <span class="hljs-attr">restaurant.id:</span> <span class="hljs-string">"rest-789"</span>
</code></pre>
<p>This tells us:</p>
<ul>
<li><p>The data came from the delivery service.</p>
</li>
<li><p>The instance handling the request is "instance-456".</p>
</li>
<li><p>It’s running in the ng-west-2 cloud region.</p>
</li>
<li><p>The environment is Production (not testing or staging), and so on.</p>
</li>
</ul>
<p>Now, engineers can answer more specific questions:</p>
<ul>
<li><p>Are deliveries slower in certain cities? (Filter by <code>customer.city</code>)</p>
</li>
<li><p>Are certain restaurants taking longer to prepare food? (Filter by <code>restaurant.id</code>)</p>
</li>
<li><p>Are delays only happening in a specific cloud region? (Filter by <code>cloud.region</code>)</p>
</li>
<li><p>Are issues only happening in production or also in staging? (Filter by <code>deployment.environment</code>)</p>
</li>
</ul>
<p>When issues arise, resource attributes allow engineers to quickly narrow down the source of problems. Rather than investigating every service, they can filter by specific attributes to focus their efforts.</p>
<h2 id="heading-how-my-project-work-fits-into-all-this"><strong>How My Project Work Fits into All This</strong></h2>
<p>Many engineers use OpenTelemetry for data collection and then send metrics to Prometheus for storage, querying, and analysis.</p>
<p>But Prometheus does not natively support resource attributes in the same way as OpenTelemetry. Instead, it relies on labels to organize metrics. Since Prometheus traditionally has its own labeling system for metrics, integrating OpenTelemetry's resource attributes creates interesting UX challenges.</p>
<p>One key challenge is the <strong>cardinality</strong> explosion. Cardinality refers to the number of unique combinations of label values (or dimensions) that a metric can have. A "cardinality explosion" occurs when you add labels with many possible values. OpenTelemetry often includes many detailed attributes that, if directly converted to Prometheus labels, would create an overwhelming number of time series. This can slow down Prometheus dramatically or even cause it to crash.</p>
<p>The existing solution involves stuffing all resource attributes into a single JSON-encoded Prometheus label. While this prevents the cardinality explosion, it makes querying extremely cumbersome. Users have to use complex join operations and specialized query syntax to filter or aggregate based on these attributes.</p>
<p>This approach is technically functional but creates a poor user experience. My research aims to understand how users mentally model the transition from OpenTelemetry's rich attribute system to Prometheus's more constrained label system.</p>
<p>The research goals are to:</p>
<ol>
<li><p>Understand how engineers currently use OpenTelemetry resource attributes with Prometheus</p>
</li>
<li><p>Identify pain points in the current integration between these systems</p>
</li>
<li><p>Discover user expectations for how resource attributes should be represented in Prometheus</p>
</li>
</ol>
<p>This work is particularly important as more organizations adopt OpenTelemetry as their instrumentation standard while continuing to use Prometheus for metrics monitoring. Creating a seamless experience between these two popular open-source projects will help improve the overall observability ecosystem.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Observability in cloud native applications is clearly an interesting subject and important for building reliable, performant systems. The tools and concepts we've explored – metrics, logs, traces, Prometheus, and OpenTelemetry – form the foundation of modern observability practices.</p>
<p>As I continue my mentorship program, I'll share more insights about how these technologies work together and try to break them down from the perspective of a first-time learner.</p>
<h2 id="heading-additional-resources">Additional Resources</h2>
<p>Learn more about:</p>
<ol>
<li><p><a target="_blank" href="https://opentelemetry.io/docs/">OpenTelemetry</a></p>
</li>
<li><p><a target="_blank" href="https://prometheus.io/docs/introduction/overview/">Prometheus</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/prometheus/prometheus/issues/15909">My UX research project</a></p>
</li>
</ol>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
