<?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[ Puneet Singh - 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[ Puneet Singh - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sun, 18 Oct 2026 06:30:00 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/psmir/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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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 to Build an Open Source Data Lake for Batch Ingestion ]]>
                </title>
                <description>
                    <![CDATA[ Creating a data platform has been made easier by cloud data analytics platforms like Databricks, Snowflake, and BigQuery. They offer excellent ramp-up and scaling options for small to mid-size teams.  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-open-source-data-lake-for-batch-ingestion/</link>
                <guid isPermaLink="false">69e0f1a7b67a275a9d3c9122</guid>
                
                    <category>
                        <![CDATA[ data-engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ apache-airflow ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ingestion ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Puneet Singh ]]>
                </dc:creator>
                <pubDate>Thu, 16 Apr 2026 14:26:47 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ef685075-beac-4bf4-b435-6e942e5e1ac1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Creating a data platform has been made easier by cloud data analytics platforms like Databricks, Snowflake, and BigQuery. They offer excellent ramp-up and scaling options for small to mid-size teams.</p>
<p>But the trade-off isn't just merely renting the outside infrastructure. It also includes proprietary abstraction lock-in, and an operational and security surface area built on top of vendor capabilities.</p>
<p>In this article, you'll set up a batch ingestion layer on an open-source data lake stack where you own every component.</p>
<p>The focus is deliberately narrow. We'll get the ingestion layer up and running end-to-end. Then we'll build on foundations that allow future extension: analytics, governance, and stream processing without locking you into any single tool for those layers. We'll also review documented integration failures along the way: misconfigured catalogs, partition values written as NULL, and Python version mismatches.</p>
<p>By the end, you'll have:</p>
<ul>
<li><p>A working single-node data lake running on Docker (compose), built on RustFS (object storage), Apache Iceberg (table format), and Project Nessie (catalog).</p>
</li>
<li><p>A batch pipeline orchestrated with Apache Airflow, executing PySpark jobs that write versioned, partitioned Iceberg tables.</p>
</li>
<li><p>A real-world ingestion pattern, an external web scraper decoupled from Airflow via Redis, writing raw data to object storage with a lightweight signal table.</p>
</li>
<li><p>A view of what this stack is and isn't, and what you'd add to take it toward production.</p>
</li>
</ul>
<p>A word on scope: this covers the E in <a href="https://www.getdbt.com/blog/extract-load-transform">ELT</a>: getting data in. Transformation (dbt, Spark SQL) and analytics (Trino, Superset) are a natural next layer, but are outside the scope of this article. What you build here is the foundation they'd sit on.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-the-ingestion-problem">The Ingestion Problem</a></p>
</li>
<li><p><a href="#heading-stack">Stack</a></p>
</li>
<li><p><a href="#heading-system-overview">System Overview</a></p>
</li>
<li><p><a href="#heading-quick-start">Quick Start</a></p>
</li>
<li><p><a href="#heading-running-the-pipelines">Running the Pipelines</a></p>
</li>
<li><p><a href="#heading-setup">Setup</a></p>
<ul>
<li><p><a href="#heading-rustfs">RustFS</a></p>
</li>
<li><p><a href="#heading-nessie">Nessie</a></p>
</li>
<li><p><a href="#heading-spark">Spark</a></p>
</li>
<li><p><a href="#heading-apache-airflow">Apache Airflow</a></p>
</li>
<li><p><a href="#heading-scrapredis">Scrapredis</a></p>
</li>
<li><p><a href="#heading-scrapworker">Scrapworker</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-path-forward">Path Forward</a></p>
<ul>
<li><p><a href="#heading-extending-capabilities">Extending Capabilities</a></p>
</li>
<li><p><a href="#heading-adding-layers">Adding Layers</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-the-ingestion-problem">The Ingestion Problem</h2>
<p>The structure of a stack/solution is easier to understand with a use case. A high-level goal is to ingest financial data from external market APIs for trend analysis. You'll focus specifically on setting up ingestion of such data into the warehouse for further analytics.</p>
<p>The data is ingested via a web crawler with a specific rate limit per endpoint. In Batch processing, time-based partitioning is effective for processing by downstream pipelines. It also favors cleaner data retention.</p>
<p>The crawler runs as an external process, decoupled from Airflow via a Redis job queue. This keeps rate limiting and crawl lifecycle outside the orchestration layer, with each component failing and recovering independently.</p>
<p>During ingestion, the priority is data landing with high reliability due to the lack of idempotency in crawl jobs.</p>
<h2 id="heading-stack">Stack</h2>
<ul>
<li><p><a href="https://rustfs.com/"><strong>RustFS</strong></a><strong>:</strong> An S3-compatible object store written in Rust</p>
</li>
<li><p><a href="https://projectnessie.org/"><strong>Project Nessie</strong></a><strong>:</strong> Transactional catalog for Apache Iceberg tables</p>
</li>
<li><p><a href="https://spark.apache.org/"><strong>Apache Spark</strong></a><strong>:</strong> Distributed compute engine</p>
</li>
<li><p><a href="https://airflow.apache.org/"><strong>Apache Airflow</strong></a><strong>:</strong> Job scheduling and orchestration</p>
</li>
<li><p><a href="https://jupyter.org/"><strong>Jupyter Notebook</strong></a> <em>(optional)</em>: Ad-hoc Spark queries against Iceberg tables, not covered in this article</p>
</li>
<li><p><strong>Scrapredis:</strong> Job queue for the web crawler</p>
</li>
<li><p><strong>Scrapworker:</strong> Web crawler and ingestion worker</p>
</li>
</ul>
<p>This setup was tested on a 4-core x86/AMD CPU, 16GB RAM, 60GB disk GCP VM running Debian GNU/Linux 11 (Bullseye). Docker with Compose v2 is required. The setup should work on any comparable Linux environment with similar or better specs.</p>
<h2 id="heading-system-overview">System Overview</h2>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/429a1e8a-bc39-44dc-8e0b-2cd9152370f5.png" alt="Data Platform Architecture" style="display: block;" width="3202" height="2385" loading="lazy">

<p>The crawler runs as an external process, decoupled from Airflow via a Redis job queue. Airflow pushes a job specification to the queue containing the endpoint, query params, and target path. The crawler picks it up, executes the crawl, and writes raw results directly to object storage.</p>
<p>This separation keeps rate limiting and crawl lifecycle concerns outside the orchestration layer, and isolates failure modes.</p>
<p>A crawl failure is harder to recover since crawl jobs lack idempotency. Pipeline failures after the crawl stage are independently retryable without re-triggering a crawl.</p>
<h2 id="heading-quick-start">Quick Start</h2>
<p>First, initialize the project:</p>
<pre><code class="language-bash"># Clone the repository
git clone https://github.com/ps-mir/data-platform

# Create the shared Docker network
docker network create data-platform

# Create host directories, set permissions, and download Spark JARs
chmod +x init.sh &amp;&amp; ./init.sh
</code></pre>
<p>Start services in this order (shutdown in reverse):</p>
<ol>
<li><strong>RustFS</strong></li>
</ol>
<pre><code class="language-bash">cd rustfs &amp;&amp; docker compose up -d
</code></pre>
<ol>
<li><strong>Nessie</strong></li>
</ol>
<pre><code class="language-bash">cd nessie &amp;&amp; docker compose up -d
</code></pre>
<ol>
<li><strong>Spark</strong> — requires a build on first run</li>
</ol>
<pre><code class="language-bash">cd spark &amp;&amp; docker compose build &amp;&amp; docker compose up -d
</code></pre>
<ol>
<li><strong>Scrapredis</strong></li>
</ol>
<pre><code class="language-bash">cd scrapredis &amp;&amp; docker compose up -d
</code></pre>
<ol>
<li><strong>Airflow</strong> — requires a build on first run</li>
</ol>
<pre><code class="language-bash">cd airflow-docker &amp;&amp; docker compose build &amp;&amp; docker compose up -d
</code></pre>
<p>Create the Nessie namespaces once after Nessie is up:</p>
<pre><code class="language-bash">curl -X POST http://localhost:19120/iceberg/v1/main/namespaces \
  -H "Content-Type: application/json" \
  -d '{"namespace": ["default"]}'

curl -X POST http://localhost:19120/iceberg/v1/main/namespaces \
  -H "Content-Type: application/json" \
  -d '{"namespace": ["scraper"]}'
</code></pre>
<p>Scrapworker runs on the host directly (it's not dockerized). It requires Python &gt;=3.14:</p>
<pre><code class="language-bash">cd scrapworker
pip install -e .
CONFIG_PATH=./config/config.local.yaml RUSTFS_ACCESS_KEY=rustfsadmin RUSTFS_SECRET_KEY=rustfsadmin python -m scrapworker
</code></pre>
<p>Scrapworker must be running before activating <code>scraper_pipeline_v1</code> in Airflow. Without it, the pipeline will push jobs to the queue with no worker to pick them up and hang indefinitely in <code>wait_for_completion</code>.</p>
<p>Trino is also present in setup but not tested for integration with Nessie yet.</p>
<h2 id="heading-running-the-pipelines">Running the Pipelines</h2>
<p>With the stack running, the next step is to activate the pipelines in Airflow. All DAGs are paused at creation by default. The four pipelines build on each other in complexity. Working through them in order is the fastest way to confirm that each layer of the stack is wired correctly before moving to the next.</p>
<p>All four pipelines are loaded but paused by default. Unpause each one in the Airflow UI before triggering.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/38f95d52-c092-4a00-b660-1233077b781b.png" alt="All Airflow Pipelines" style="display: block;" width="2678" height="1234" loading="lazy">

<p>Let's go over each pipeline:</p>
<h3 id="heading-sparkstaticdatav1skeleton-hello-dag">spark_static_data_v1_skeleton: <a href="https://github.com/ps-mir/data-platform/blob/07ad47d68fec51f48cd41560921d509a70c5bb6f/airflow-docker/dags/step1_hello_dag.py">Hello DAG</a></h3>
<p>This is a minimal DAG with no Spark, just a Python task that prints a message. If it goes green, Airflow's scheduler and worker are healthy. <code>[2026-04-09 22:00:01] INFO - Task operator:&lt;Task(_PythonDecoratedOperator): say_hello&gt;</code></p>
<h3 id="heading-sparkstaticdatav2submit-spark-submit">spark_static_data_v2_submit: <a href="https://github.com/ps-mir/data-platform/blob/07ad47d68fec51f48cd41560921d509a70c5bb6f/airflow-docker/dags/step2_spark_submit.py">Spark Submit</a></h3>
<p>This submits a PySpark job via <code>SparkSubmitOperator</code> that writes a static dataset to an Iceberg table. No partitioning, every run overwrites the previous content.</p>
<p>In Nessie catalog it appears as:</p>
<pre><code class="language-bash">Type: ICEBERG_TABLE
Metadata Location:s3://warehouse/default/static_data_e7e43123-95a7-44d2-b6d5-67c9c7aa4321/metadata/00000-08a5a2db-6f12-4f21-b2a9-de3d9123fbd3.metadata.json
</code></pre>
<h3 id="heading-sparkpartitioneddatav1-spark-partitioned">spark_partitioned_data_v1: <a href="https://github.com/ps-mir/data-platform/blob/07ad47d68fec51f48cd41560921d509a70c5bb6f/airflow-docker/dags/step3_spark_partitioned.py">Spark Partitioned</a></h3>
<p>This extends step2 with time-based partitioning. Partition values are derived from the scheduled slot time, so every run writes to its own <code>(ds, hr, min)</code> partition without touching previous ones.</p>
<p>Example file path in RustFS: <code>warehouse/default/static_data_partitioned_b172c66f-722b-44f3-bbee-069355753ff6/data/ds=2026-03-28/hr=23/min=15/00000-4-7a196a47-2ac0-4023-af68-ca10487fccb2-0-00001.parquet</code></p>
<h3 id="heading-scraperpipelinev1-scraper-pipeline">scraper_pipeline_v1: <a href="https://github.com/ps-mir/data-platform/blob/07ad47d68fec51f48cd41560921d509a70c5bb6f/airflow-docker/dags/scraper_pipeline.py">Scraper Pipeline</a></h3>
<p>This is the full ingestion flow. Airflow pushes a job to Scrapredis, Scrapworker calls the Binance API and writes raw results to RustFS, then Airflow publishes a signal row to the Nessie catalog.</p>
<p>Every run fetches: <code>https://api.binance.com/api/v3/trades?symbol=BTCUSDT&amp;limit=10</code></p>
<h2 id="heading-setup">Setup</h2>
<p>This is a single-node development setup using Docker Compose. It's built on a well-structured base config that can be extended to production with targeted changes.</p>
<ul>
<li><p>A production deployment would require HA configuration, persistent volume management, and security hardening for each component.</p>
</li>
<li><p>Images are pinned to specific versions to avoid silent breakage between pulls.</p>
</li>
<li><p>All containers share a common external Docker network named <code>data-platform</code>, which allows services to communicate using container names as hostnames.</p>
</li>
<li><p>An <code>init.sh</code> script creates the required local dirs inside the data folder and also creates the Docker network.</p>
</li>
</ul>
<h3 id="heading-rustfs">RustFS</h3>
<p>RustFS is the object storage layer in this stack. Nessie's REST catalog mode has a hard dependency on an S3-compatible endpoint. Running it against a local filesystem fails the Nessie healthcheck at startup and causes catalog initialization to error out. The REST catalog is the recommended mode for new setups because it enables credential vending and multi-engine coordination.</p>
<p>MinIO was the natural choice for self-hosted S3-compatible storage, but it shifted to a more restrictive license. RustFS is the open-source alternative, written in Rust and backed by local disk.</p>
<p>At write time, Spark pushes Parquet files directly to RustFS via S3FileIO. Nessie commits the table metadata alongside, so data and catalog state land together or not at all. This is <a href="https://iceberg.apache.org/">Apache Iceberg</a>'s core guarantee: atomic commits across both data files and metadata.</p>
<p>For production or cloud deployments, managed object storage services like AWS S3, Google Cloud Storage, or Azure Blob Storage are the natural next step. Self-hosted alternatives at scale include <a href="https://github.com/seaweedfs/seaweedfs">SeaweedFS</a>, <a href="https://docs.ceph.com/en/latest/radosgw/">Ceph/RGW</a>, and <a href="https://garagehq.deuxfleurs.fr/">Garage</a>.</p>
<h4 id="heading-notes">Notes:</h4>
<ul>
<li><p><strong>Bucket creation:</strong> A <code>rustfs-init</code> sidecar using <code>amazon/aws-cli</code> runs after RustFS passes its healthcheck and creates the <code>s3://warehouse</code> bucket automatically. You don't create the bucket manually.</p>
</li>
<li><p><strong>Permissions:</strong> RustFS runs as uid=10001 inside the container. The host directories (<code>data/rustfs/data</code> and <code>data/rustfs/applogs</code>) must be owned by that uid before the container starts, or it will fail silently. <code>init.sh</code> handles this with <code>sudo chown -R 10001:10001</code>.</p>
</li>
<li><p><strong>Image pinning:</strong> The compose file pins to <code>rustfs/rustfs:1.0.0-alpha.85-glibc</code>. Before upgrading, verify the uid hasn't changed: <code>docker run --rm --entrypoint id rustfs/rustfs:&lt;new-tag&gt;</code>. If it has, re-run <code>init.sh</code> or re-chown manually.</p>
</li>
<li><p><strong>Spark writes:</strong> Spark writes data files directly to RustFS via S3FileIO. Nessie only manages catalog metadata, it doesn't proxy data. The two interact at commit time, not at write time.</p>
</li>
</ul>
<h3 id="heading-nessie">Nessie</h3>
<p>The catalog tracks the list of tables in the warehouse, along with their data files and schema. Without it, it's hard for Spark to agree on what's in the warehouse.</p>
<p><a href="https://hive.apache.org/docs/latest/admin/adminmanual-metastore-administration/">Hive Metastore</a> offers a Thrift-based API and has been the catalog standard for years. It provides transaction semantics on metadata updates through its backing database, but those transactions stop at the catalog layer. Data files underneath aren't part of the same commit, and there's no cross-table history beyond what the database retains.</p>
<p>Apache Iceberg closes the data and metadata gap with atomic table commits. Nessie builds on that and goes further: it treats the catalog like a Git repository. Every table write is a commit. You can branch, tag, and roll back across multiple tables atomically.</p>
<p>Spark reads and writes table metadata through Nessie's Iceberg REST endpoint. Catalog state is persisted to Postgres, so it survives container restarts.</p>
<h4 id="heading-namespace-bootstrap">Namespace bootstrap</h4>
<p>Unlike Hive Metastore, Nessie doesn't auto-create namespaces. Attempting to write a table to a namespace that doesn't exist fails after data has already been written to RustFS, leaving orphaned files with no catalog entry. Namespaces are structural metadata and belong in a one-time bootstrap step, not in a pipeline.</p>
<p>Nessie manages the Iceberg catalog metadata under <code>s3://warehouse/</code>. Iceberg table data lands under paths derived from the namespace, for example, <code>s3://warehouse/default/</code> for the <code>default</code> namespace.</p>
<h4 id="heading-s3-credential-configuration-issue">S3 Credential Configuration Issue</h4>
<p>Nessie's S3 credential fields don't accept plain strings (likely for security reasons). They require a secret URI in the form <code>urn:nessie-secret:quarkus:&lt;name&gt;</code> even for local credentials.</p>
<p>Additionally, the SCREAMING_SNAKE_CASE environment variable convention is ambiguous for Quarkus property names containing hyphens. The property is silently ignored, and the default (which fails) is used instead. The working approach is dot-notation keys passed directly in the compose environment block, which Quarkus reads without conversion:</p>
<pre><code class="language-properties">nessie.catalog.service.s3.default-options.access-key: "urn:nessie-secret:quarkus:nessie.catalog.secrets.access-key"
nessie.catalog.secrets.access-key.name: rustfsadmin
nessie.catalog.secrets.access-key.secret: rustfsadmin
</code></pre>
<h4 id="heading-nessie-health-check">Nessie health check</h4>
<p>Once the RustFS settings are corrected, Nessie's health check URL(<a href="http://localhost:9090/q/health">http://localhost:9090/q/health</a>) should return the following response:</p>
<pre><code class="language-json">{
    "status": "UP",
    "checks": [
        {
            "name": "MongoDB connection health check",
            "status": "UP"
        },
        {
            "name": "Warehouses Object Stores",
            "status": "UP",
            "data": {
                "warehouse.warehouse.status": "UP"
            }
        },
        {
            "name": "Database connections health check",
            "status": "UP",
            "data": {
                "&lt;default&gt;": "UP"
            }
        }
    ]
}
</code></pre>
<p>The MongoDB connection health check appears in the response even though this stack doesn't use MongoDB. It's a Quarkus built-in probe registered automatically regardless of store type. With JDBC configured, MongoDB is never connected and the UP report is just a placeholder response.</p>
<h4 id="heading-catalog-endpoint-vs-management">Catalog endpoint vs Management</h4>
<p>Nessie exposes two separate APIs. The Iceberg REST catalog is at <code>/iceberg</code>. This is what Spark and Trino connect to. The Nessie management API is at <code>/api/v2</code>, which is for branch operations, commit history, and table inspection. They aren't interchangeable.</p>
<pre><code class="language-properties"># Iceberg REST API
http://localhost:19120/iceberg/v1/main/namespaces
http://localhost:19120/iceberg/v1/config

# Nessie management API
http://localhost:19120/api/v2/config
</code></pre>
<h4 id="heading-notes">Notes:</h4>
<ul>
<li><p><code>path-style-access: true</code> is required for any non-AWS S3 endpoint. <code>region</code> is a dummy value required by the AWS SDK internally.</p>
</li>
<li><p>Nessie's internal port 9000 is remapped to 9090 on the host to avoid conflict with RustFS which occupies 9000 and 9001.</p>
</li>
</ul>
<h4 id="heading-forward-path">Forward path</h4>
<p>Nessie is a stateless REST service, so scaling reads can be done with LB with no coordination between nodes. Durability comes entirely from backend store.</p>
<h3 id="heading-spark">Spark</h3>
<p>As a distributed compute engine, Apache Spark is a reliable and stable choice for long-running jobs. In the current setup, it executes PySpark jobs submitted by Airflow, reads and writes Iceberg tables via the Nessie REST catalog, and writes data files directly to RustFS using S3FileIO. Spark runs in standalone mode with a single master and worker, configured via <code>spark-defaults.conf</code>.</p>
<p>Two JARs are required and must be placed in <code>data/spark/jars/</code> before starting:</p>
<ul>
<li><p><code>iceberg-spark-runtime-3.5_2.12</code>: Iceberg integration for Spark: SparkCatalog, DataFrameWriterV2, SQL extensions, and all table format logic.</p>
</li>
<li><p><code>iceberg-aws-bundle</code>: AWS SDK v2 and Iceberg's S3FileIO, the storage transport layer for writing data files to RustFS. The Spark base image ships only Hadoop AWS (SDK v1). This bundle provides the SDK v2 classes that S3FileIO requires.</p>
</li>
</ul>
<p>Spark uses a custom Dockerfile to install Python 3.12. Build the image before first use:</p>
<pre><code class="language-bash">cd spark
docker compose build
docker compose up -d
</code></pre>
<p>The PySpark jobs are covered in the Airflow section, where we walk through each DAG and its corresponding Spark script as part of the pipeline.</p>
<p>Before submitting any Spark job that writes an Iceberg table, the target namespace must exist in Nessie. Nessie doesn't auto-create namespaces, unlike Hive Metastore. Attempting to write to a missing namespace fails after data has already been written to RustFS, leaving orphaned files with no catalog entry.</p>
<p>Create the <code>default</code> namespace once before running any pipeline:</p>
<pre><code class="language-bash"># Nessie should be up and running at this point
curl -X POST http://localhost:19120/iceberg/v1/main/namespaces \
  -H "Content-Type: application/json" \
  -d '{"namespace": ["default"]}'
{
  "namespace" : [ "default" ],
  "properties" : { }
}
</code></pre>
<p>Verify:</p>
<pre><code class="language-bash">curl http://localhost:19120/iceberg/v1/main/namespaces
</code></pre>
<h4 id="heading-catalog-mismatch-tables-missing-across-query-engines">Catalog Mismatch: Tables Missing Across Query Engines</h4>
<p>If tables written by Spark aren't visible in Trino, the likely cause is a catalog mismatch. Spark configured with <code>NessieCatalog</code> and Trino using the Iceberg REST catalog maintain separate metadata views — they don't share table state. Both engines must point at the same catalog endpoint: <code>http://nessie:19120/iceberg</code>.</p>
<h4 id="heading-notes">Notes:</h4>
<ul>
<li><p><strong>Worker memory:</strong> The worker is configured with <code>SPARK_WORKER_MEMORY: 8g</code>. Spark's default is 1g is enough to register but not enough to run a job without queuing. Tune this based on available host memory.</p>
</li>
<li><p><strong>Remote signing:</strong> <code>remote-signing-enabled: false</code> Nessie's REST catalog supports credential vending via IAM/STS, but since that integration isn't present here, remote signing is disabled explicitly to avoid request failures.</p>
</li>
<li><p><strong>Config changes need full restart:</strong> Docker file-level bind mounts cache the inode at container start. Editing <code>spark-defaults.conf</code> won't take effect until Spark and the Airflow worker are restarted. In client mode, the Airflow worker is the Spark driver (the process that reads the config on job submission) and must be restarted too.</p>
</li>
<li><p><strong>Jupyter Notebook:</strong> A Jupyter instance with PySpark is included in the stack for ad-hoc queries against Iceberg tables. It connects to the same Spark cluster and Nessie catalog, so any table written by a pipeline is immediately queryable.</p>
</li>
</ul>
<p>⚠️ <strong>Warning:</strong> The Spark worker and Airflow worker (the driver) must run the same Python minor version. PySpark enforces this at runtime and fails immediately if they diverge. The Spark image in this stack uses a custom Dockerfile to install Python 3.12, matching Airflow's base image. If you upgrade either, verify that the versions stay aligned.</p>
<h3 id="heading-apache-airflow">Apache Airflow</h3>
<p>Airflow makes it easier to author, schedule and monitor workflows. In this case, it handles the ingestion for batch processing, but it can be extended to use cases like stream processing.</p>
<p>The Airflow components resemble more closely the DAG processor Airflow Architecture from the <a href="https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/overview.html">official docs</a>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/a438e02b-0b16-44c7-bcae-92c954a942cc.png" alt="DAG Processor Airflow Architecture" style="display: block;" width="2308" height="1455" loading="lazy">

<p>Key aspects:</p>
<ul>
<li><p>The DAG Processor continuously parses DAG files and serializes them to the Metadata DB.</p>
</li>
<li><p>The Scheduler reads from there, detects when a DAG run is due, creates task instances, and pushes them to the CeleryExecutor (via Redis queue).</p>
</li>
<li><p>The Celery worker picks up a task and executes it. In the case of a <code>SparkSubmitOperator</code>, the worker process becomes the Spark driver, submitting the job to the Spark cluster.</p>
</li>
<li><p>Executors run on the Spark worker, write Parquet files directly to RustFS, and commit the table metadata to Nessie. Airflow records the task outcome back in the Metadata DB.</p>
</li>
</ul>
<p>Airflow uses a custom Dockerfile to install Java 17 and additional providers. Build the image before first use:</p>
<pre><code class="language-bash">cd airflow-docker
docker compose build
docker compose up -d
</code></pre>
<h4 id="heading-pipelines">Pipelines</h4>
<p>Pipelines need to be created inside <code>airflow-docker/dags</code> folder for dag processor to pick up load the pipeline DAG in metadata DB. Four pipeline examples are provided with varying complexity.</p>
<ol>
<li><p><code>step1_hello_dag.py</code>: single-task DAG with no dependencies, just a Python function that prints a message.</p>
</li>
<li><p><code>step2_spark_submit.py</code>: submits a PySpark job via SparkSubmitOperator. The job writes a static dataset to an Iceberg table via the Nessie catalog.</p>
</li>
<li><p><code>step3_spark_partitioned.py</code>: extends step 2 with time-based partitioning. The scheduled slot time is passed to the PySpark script.</p>
<ul>
<li>Time-based partition values are derived from <code>data_interval_start</code> for idempotency (Backfill, Reruns).</li>
</ul>
</li>
<li><p><code>scraper_pipeline</code>: a real-world ingestion pipeline. Coordinates with the external task executor <code>scrapworker</code> via the Redis queue <code>scrapredis</code>.</p>
<ul>
<li>Both <code>scrapredis</code> and <code>scrapworker</code> must be up and running for this pipeline to work.</li>
</ul>
</li>
</ol>
<h4 id="heading-deploy-mode-and-driver-config">Deploy Mode and Driver Config</h4>
<p>The initial <code>SparkSubmitOperator</code> configuration used <code>deploy_mode="cluster"</code>, which runs the driver on the Spark cluster rather than the submitting machine. This fails immediately on Spark standalone clusters with a hard error:</p>
<pre><code class="language-plaintext">Cluster deploy mode is currently not supported for python applications on standalone clusters.
</code></pre>
<p>Cluster mode for Python is only available on YARN and Kubernetes. The fix is <code>deploy_mode="client"</code>, but this shifts the problem: in client mode, the driver runs on the Airflow worker container, which means the worker needs everything the Spark containers have.</p>
<p>Overall, three changes are required in the Airflow worker:</p>
<ul>
<li><p>The Iceberg and Nessie JARs at <code>/opt/spark/user-jars/</code></p>
</li>
<li><p><code>spark-defaults.conf</code> with catalog, extension, and JAR config</p>
</li>
<li><p><code>SPARK_CONF_DIR=/opt/spark/conf</code>, without this, pip-installed PySpark's <code>spark-submit</code> silently ignores the mounted conf file and runs with no catalog config</p>
</li>
</ul>
<p>The fix was adding all three to <code>x-airflow-common</code> in <code>airflow-docker/docker-compose.yaml</code> so every Airflow service inherits them:</p>
<pre><code class="language-yaml">environment:
  SPARK_CONF_DIR: /opt/spark/conf

volumes:
  - ../data/spark/jars:/opt/spark/user-jars:ro
  - ../spark/spark-defaults.conf:/opt/spark/conf/spark-defaults.conf:ro
</code></pre>
<h4 id="heading-partition-values-written-as-null">Partition Values Written as NULL</h4>
<p>When the third pipeline (Spark Partitioned) ran for the first time, the data landed correctly in RustFS, but querying the Iceberg partitions metadata showed:</p>
<pre><code class="language-plaintext">+------------------+----------+
|         partition|file_count|
+------------------+----------+
|{NULL, NULL, NULL}|         2|
+------------------+----------+
</code></pre>
<p>The original script used Spark's DataSource V1 API:</p>
<pre><code class="language-python">df.write.format("iceberg").mode("overwrite").saveAsTable(table)
</code></pre>
<p>The script used Spark's V1 DataFrame write API with format("iceberg"), which loads an isolated table reference and bypasses Iceberg's catalog write path. As a result, Iceberg committed the data files to storage but wrote NULL partition values into the manifest metadata.</p>
<p>The fix is in Iceberg's native DataFrameWriterV2 API:</p>
<pre><code class="language-python">df.writeTo(table).overwritePartitions()
</code></pre>
<p>This routes through Iceberg's native write path, evaluates partition transforms from the real column values (ds, hr, min), and registers them correctly in the manifest. <code>overwritePartitions()</code> overwrites only the partitions present in the DataFrame. A rerun with the same scheduled time produces the same values and atomically replaces that partition, leaving all others untouched.</p>
<p>⚠️ Existing NULL-partition manifest entries aren't retroactively corrected by subsequent V2 writes. For a brand-new table containing only bad data, DROP TABLE and rewrite is the simplest recovery.</p>
<h3 id="heading-scrapredis">Scrapredis</h3>
<p>Scrapredis is a dedicated Redis instance that sits between Airflow and Scrapworker as a job queue. It's separate from Airflow's internal Redis, which exists solely for CeleryExecutor task dispatch. The separation means the crawler's job queue can be managed, scaled, or replaced without touching Airflow's internals.</p>
<p>The pattern generalises beyond scraping. Any external process that needs its own lifecycle, resource profile, or rate limiting can be wired the same way: Airflow pushes a job, the external worker pops it, and Airflow polls for the result.</p>
<p>The scraper pipeline follows this round-trip:</p>
<ol>
<li>Airflow pushes the job payload to the queue:</li>
</ol>
<pre><code class="language-python">QUEUE_KEY = "scrapworker:jobs"
client.lpush(QUEUE_KEY, json.dumps(payload))
</code></pre>
<ol>
<li>Scrapworker blocks on the queue and pops the next job:</li>
</ol>
<pre><code class="language-python">while True:
    _, payload = client.blpop(redis_cfg["queue_key"])
</code></pre>
<ol>
<li>Once the crawl finishes, Scrapworker writes the outcome and <code>s3_path</code> back to Redis:</li>
</ol>
<pre><code class="language-python">client.set(status_key, json.dumps({"status": "finished", "worker_id": worker_id, "s3_path": job["s3_path"]}), ex=TERMINAL_TTL)
</code></pre>
<ol>
<li>The <code>wait_for_completion</code> task polls for that status key. On success, <code>publish_nessie_signal</code> picks up the <code>s3_path</code> and writes the signal row to Nessie.</li>
</ol>
<h3 id="heading-scrapworker">Scrapworker</h3>
<p>Scrapworker is a Python app that uses the Scrapy crawl framework to crawl all pages of the request. It's decoupled from Airflow due to URL/client specific rate limit semantics. For simplicity, consider it a type of external worker that receives and executes requests from Airflow.</p>
<p>It's responsible for downloading and writing content to object storage (RustFS). The Nessie catalog update is decoupled and kept in a separate Airflow pipeline task.</p>
<h4 id="heading-fixed-signal-table">Fixed Signal Table</h4>
<p>Scrapworker writes raw JSON to RustFS rather than writing scraped data directly as Iceberg columns. The pipeline then publishes a single lightweight signal row to a Nessie-managed Iceberg table.</p>
<p>The signal schema is fixed and minimal (<code>run_id</code>, <code>endpoint</code>, <code>s3_path</code>, <code>ds</code>, <code>hr</code>, <code>min</code>, <code>published_at</code>). It never changes, regardless of what's being scraped.</p>
<p>Mirroring the scraped payload as Iceberg columns would force Scrapworker to own schema evolution across different endpoints. This isn't an ideal place for schema ownership. Instead, schema ownership sits downstream:</p>
<pre><code class="language-plaintext">Scrapworker  →  raw files in RustFS  +  signal row in Iceberg (from Pipeline)
Airflow job  →  reads raw via s3_path, applies schema, writes structured Iceberg table
</code></pre>
<p>The downstream job knows the domain, knows the schema, and is the right place to handle type casting, nulls, and partition layout. Scrapworker stays generic and thin — the same code handles any endpoint without modification.</p>
<h4 id="heading-why-signal-publish-is-a-separate-airflow-task">Why Signal Publish is a Separate Airflow Task</h4>
<p>Scrapworker writes to RustFS and sets <code>status: finished</code> in Redis with the <code>s3_path</code>. A separate Airflow task reads that status and publishes the signal row to Nessie. The two writes are intentionally decoupled.</p>
<p>If scrapworker published to Nessie directly after writing to RustFS, the two writes would share a failure mode. A Nessie failure after a successful RustFS write would leave data stranded with no signal and no clean recovery path. The only option would be a re-crawl which lacks idempotency.</p>
<p>With the decoupled approach, each failure is isolated. A Nessie failure triggers an Airflow retry of the signal publish task only, no re-scrape, no duplicate crawl. RustFS and Nessie failures are independently recoverable.</p>
<h4 id="heading-notes">Notes:</h4>
<ul>
<li><p>Raw scraped files are written directly to <code>s3://warehouse/raw/</code>, entirely outside Nessie's management. Nothing in the Iceberg layer touches this path.</p>
</li>
<li><p>The scrapworker signal table lives in a dedicated <code>scraper</code> namespace. Create it once before scrapworker runs for the first time.</p>
</li>
</ul>
<pre><code class="language-bash">curl -X POST http://localhost:19120/iceberg/v1/main/namespaces \
  -H "Content-Type: application/json" \
  -d '{"namespace": ["scraper"]}'
</code></pre>
<h2 id="heading-path-forward">Path Forward</h2>
<p>The stack we've built here is a working ingestion layer. It lands data reliably, tracks it in a versioned catalog, and gives you a foundation to build on. Two directions are worth considering from here.</p>
<h3 id="heading-extending-capabilities">Extending Capabilities</h3>
<p>These are improvements to what's already in the stack, making it more robust without adding new components.</p>
<p><strong>Ingestion reliability:</strong> Scrapworker currently handles failures by setting <code>status: failed</code> in Redis, which requires Airflow to re-trigger the full pipeline. Adding client-side rate limiting and per-endpoint retry logic with backoff would make crawl jobs more self-healing, so that a failed page fetch can retry independently without surfacing to Airflow at all.</p>
<p><strong>Config validation:</strong> A misconfigured endpoint schema in <code>config.yaml</code> fails silently at runtime, often deep into a crawl. A <code>validate_config()</code> call at startup would catch missing required fields like <code>offset_param</code> or <code>response_map</code> before any job runs. This becomes more important as more endpoints are added.</p>
<p><strong>Observability:</strong> Airflow alerting and SLA monitoring give early warning when pipelines miss their schedule or tasks take longer than expected. The signal table is useful here too. A lightweight monitor that checks for expected signal rows within a time window is a simple SLA check that works without external tooling.</p>
<h3 id="heading-adding-layers">Adding Layers</h3>
<p>These are new capabilities that build on the ingestion foundation.</p>
<p><strong>Transform layer:</strong> The raw Iceberg tables written by the ingestion layer are the input for a transform step. dbt or Spark SQL can read from raw, apply schema, clean types, and write structured tables to a separate namespace. This is the L in ELT and the natural next step once ingestion is stable.</p>
<p><strong>Analytics:</strong> Trino is already in the stack and partially integrated. Connecting it fully to Nessie enables SQL queries across all Iceberg tables. Adding Superset on top gives a visualisation layer without requiring any changes to the ingestion pipeline.</p>
<p><strong>Broader source onboarding:</strong> The current stack handles one ingestion pattern: a scheduled Airflow pipeline triggering an external HTTP crawler. The same foundation supports pull-based sources like databases using CDC, and push-based sources like event streams via Kafka. The Iceberg tables and Nessie catalog serve as the landing zone regardless of how data arrives.</p>
<p><strong>Governance:</strong> Iceberg and Nessie provide the foundations, covering snapshots, schema evolution, commit history, and time travel. The governance layer on top requires deliberate additions: access control, data quality checks, lineage tracking, and schema enforcement. None of these require replacing what's here, as they sit on top of it.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
