<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged opentelemetry</title>
<link>https://coderlegion.com/tag/opentelemetry</link>
<description>Powered by Question2Answer</description>
<item>
<title>How to evaluate agents with one trace across MCP, A2A and Realtime</title>
<link>https://coderlegion.com/31093/how-to-evaluate-agents-with-one-trace-across-mcp-a2a-and-realtime</link>
<description>&lt;p&gt;A support agent passes every eval in staging. In production the same agent is reached three ways: from an IDE over MCP, from the web app over the OpenAI Responses API, and from a triage agent over A2A. The dashboard stays green for weeks. Then a customer escalation shows the triage agent handed over a ticket without the customer's region, and the support agent quoted the refund policy for the wrong country. No evaluator flagged it, because no evaluator ever looked at the hop.&lt;/p&gt;
&lt;p&gt;The reason is structural. Eval is a property of the path a request took, and the protocol defines the path. Most eval stacks were written against one surface, usually chat completions, so they score whatever still looks like a chat completion and quietly skip the rest. A protocol-neutral eval stack scores the path instead, whichever protocol carried the request.&lt;/p&gt;
&lt;h4 id=&quot;every-protocol-adds-seams-to-the-path&quot;&gt;Every protocol adds seams to the path&lt;/h4&gt;
&lt;p&gt;A plain chat completion has one path: prompt in, response out. One rubric can grade it. Each protocol you add forks that path into more decision points, and each decision point needs its own check.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;MCP adds a tool catalog.&lt;/strong&gt; The path becomes prompt, tool descriptor list, tool selection, call arguments, tool result, final answer. Score each tool call against policy, and score the catalog as a whole, because a poisoned descriptor looks fine at call time and only shows up against the full catalog.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A2A turns the path into a tree.&lt;/strong&gt; Every hop needs three answers: did the calling agent pass enough context, did the called agent stay in scope, and did the returned artifact match the task that was asked for.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Responses API puts three outputs in one envelope.&lt;/strong&gt; Structured JSON gets graded against a schema, streamed text gets graded as it arrives, and a tool call gets graded against a per-tool policy. One end-to-end pass mixes three things that share nothing except the wrapper.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Realtime has no final message.&lt;/strong&gt; Audio streams in both directions, so checks have to run mid-stream, every N tokens or N audio chunks, with a stop or disclaimer action when a rule fires. The end-to-end budget sits below 500ms, which caps how heavy each check can be.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Inline vision needs a second judge.&lt;/strong&gt; When images ride in the same message as tool blocks, the text portion and the image portion need different rubrics, and the image rubric needs a judge that can open the image.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Session state spans turns.&lt;/strong&gt; On a framework with explicit session state, the evaluator has to read state across turns instead of grading each turn alone.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;trace-fidelity-caps-eval-fidelity&quot;&gt;Trace fidelity caps eval fidelity&lt;/h4&gt;
&lt;p&gt;An evaluator can only score what the trace can represent. If your trace has no way to express an agent-to-agent hop or a streaming chunk, no rubric downstream will catch a failure inside one.&lt;/p&gt;
&lt;p&gt;So the first fix is in instrumentation, before any rubric work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Model both sides of a hop.&lt;/strong&gt; Emit one span kind on the calling agent and another on the called agent, and carry the trace ID across the wire so the whole multi-agent path lands in one trace.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give each modality its own span kind.&lt;/strong&gt; Voice, image and computer-use spans should be distinguishable from text spans, so each one reaches an evaluator built for that modality.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Record guardrail and evaluator results as spans.&lt;/strong&gt; When a guardrail decision and an evaluator score sit in the same trace as the model call, you can see which check ran on which step.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The MCP spec helps here. The 2026-07-28 revision documents trace context propagation through &lt;code&gt;_meta&lt;/code&gt; (&lt;code&gt;traceparent&lt;/code&gt;, &lt;code&gt;tracestate&lt;/code&gt;, &lt;code&gt;baggage&lt;/code&gt;), so MCP tool calls can join the same trace as the rest of the agent loop.&lt;/p&gt;
&lt;h4 id=&quot;normalize-the-spans-then-write-each-rubric-once&quot;&gt;Normalize the spans, then write each rubric once&lt;/h4&gt;
&lt;p&gt;The same agent identity shows up differently on every protocol. If you write a groundedness check against raw MCP payloads and another against raw Responses payloads, you now maintain two checks that drift apart.&lt;/p&gt;
&lt;p&gt;Normalize first. Map every span to one semantic convention, such as the OpenTelemetry GenAI conventions, before any evaluator reads it. Then write rubrics against the normalized trace. One groundedness rubric can then grade a RAG answer that arrived over the Responses API and a RAG answer that arrived over MCP, and one task-completion rubric can grade the support agent no matter which client reached it.&lt;/p&gt;
&lt;p&gt;This is the same move observability teams made with OpenTelemetry. Teams that locked to one vendor's tracing format rebuilt their instrumentation every time they switched vendors. Agent protocols are repeating that pattern one layer up.&lt;/p&gt;
&lt;h4 id=&quot;gate-releases-per-protocol&quot;&gt;Gate releases per protocol&lt;/h4&gt;
&lt;p&gt;A single overall score hides which surface regressed. A drop in the MCP tool-audit rubric and a drop in the Realtime guardrail are different failures with different fixes.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Keep a regression dataset per protocol surface.&lt;/strong&gt; Run the matching rubric set against it on every change.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Set a threshold per protocol.&lt;/strong&gt; Fail the release when any one surface drops below its own threshold, even if the blended number looks fine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Key each suite on protocol and version.&lt;/strong&gt; MCP's 2026-07-28 revision removed the &lt;code&gt;initialize&lt;/code&gt; handshake, so every request now carries its protocol version in &lt;code&gt;_meta&lt;/code&gt;, and the new deprecation policy gives every deprecated feature a minimum twelve-month window. Tag each regression case with the revision it ran on, and keep the tests for deprecated features until their window closes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep the rubric sets in the repo as code.&lt;/strong&gt; Reviewers should be able to read exactly what gets scored on each surface.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;a-five-step-readiness-audit&quot;&gt;A five-step readiness audit&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Inventory every surface in production.&lt;/strong&gt; MCP servers, A2A endpoints, streaming, voice, vision and computer-use, with the protocol version and client library for each. Most teams find one they did not plan for, usually an MCP server a developer installed last sprint.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Instrument and pull one real trace per surface.&lt;/strong&gt; Confirm the hop spans appear on both sides with the propagated trace ID, and that voice and image calls emit their own span kinds. Fix gaps before writing rubrics.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write the rubric set for each protocol.&lt;/strong&gt; Per-tool audit on MCP, per-hop audit on A2A, split rubrics on the Responses API, mid-stream checks on Realtime, an image-capable judge for vision.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run a per-protocol regression suite and gate on per-protocol thresholds.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cluster production failures by protocol.&lt;/strong&gt; Failures that look like noise one at a time often form a pattern once you group them by surface.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If you only have half a day, do step 1. The list is usually longer than the team expects, and it tells you which evaluators are scoring the wrong path today.&lt;/p&gt;
&lt;h4 id=&quot;pitfalls-that-keep-showing-up&quot;&gt;Pitfalls that keep showing up&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Evaluators hard-coded to the chat completions shape.&lt;/strong&gt; Every new protocol triggers a rewrite.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scoring each agent in isolation.&lt;/strong&gt; The end-to-end view looks green while a handoff in the middle dropped context.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Post-call checks only on streaming surfaces.&lt;/strong&gt; By the time the evaluator sees the output, the risky tokens are already on the wire.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accepting tool descriptors from any MCP server without a scan.&lt;/strong&gt; An untrusted server can change what your agent does, and the eval layer never sees it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scoring voice and computer-use on text alone.&lt;/strong&gt; Errors at the ASR layer and the vision layer stay invisible.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The span kinds, the rubric set for each of the six protocols and the full audit are in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://futureagi.com/blog/llm-eval-vs-mcp-protocol-evolution-2026/?utm_source=fagiCoderlegion&amp;amp;utm_medium=organic&amp;amp;utm_campaign=blogdistribution&quot;&gt;longer protocol-neutral eval playbook&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;New protocols and revisions will keep arriving. Score the path the request took, and each new surface plugs into a trace model and a rubric library you already maintain.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31093/how-to-evaluate-agents-with-one-trace-across-mcp-a2a-and-realtime</guid>
<pubDate>Wed, 14 Oct 2026 16:22:41 +0000</pubDate>
</item>
<item>
<title>Stop Paying $0.05 Per Trace: Building an OTel-Native LLM Cost Attribution Gateway</title>
<link>https://coderlegion.com/30423/stop-paying-0-05-per-trace-building-an-otel-native-llm-cost-attribution-gateway</link>
<description>&lt;h2 id=&quot;stop-paying-0-05-per-trace-building-an-otel-native-llm-cost-attribution-gateway&quot;&gt;Stop Paying $0.05 Per Trace: Building an OTel-Native LLM Cost Attribution Gateway&lt;/h2&gt;
&lt;p&gt;When deploying generative AI into production, the second line item on your cloud bill isn't inference—it's the telemetry tax. Commercial LLM observability tools charge steep monthly per-seat licenses alongside predatory micro-billing for every trace, span, and log ingested. Worse, they require proprietary SDKs that pollute your clean codebase with vendor-specific client wrappers.&lt;/p&gt;
&lt;p&gt;In this guide, we will design and deploy a zero-lock-in, self-hosted LLM proxy using Python (FastAPI + AsyncIO) that natively adopts &lt;strong&gt;OpenTelemetry GenAI Semantic Conventions (v1.26+)&lt;/strong&gt;. It intercepts inbound OpenAI and Anthropic inference calls, attributes micro-cent usage to individual tenants, and exports standard OTLP spans directly to Grafana Tempo, ClickHouse, or Jaeger.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-bottleneck-proprietary-silos-vs-standard-otel&quot;&gt;1. The Bottleneck: Proprietary Silos vs. Standard OTel&lt;/h3&gt;
&lt;p&gt;Most commercial LLM observability platforms function as data traps. They rely on monkey-patching libraries like &lt;code&gt;openai&lt;/code&gt; or &lt;code&gt;langchain&lt;/code&gt; with closed-source telemetry hooks. When your application scales to millions of requests:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Data Ingestion Tax&lt;/strong&gt;: Charging $0.005–$0.05 per trace turns a $2,000 OpenAI bill into an additional $1,500 observability invoice.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Egress &amp;amp; Compliance Friction&lt;/strong&gt;: Sending raw prompts and completions through a closed third-party SaaS breaks strict enterprise compliance requirements (SOC2, HIPAA, GDPR).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Broken Tracing Context&lt;/strong&gt;: When an LLM trace lives in a walled-garden dashboard, you cannot correlate slow generation latency with upstream database queries or downstream background queue workers in your primary APM.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The solution is simple: &lt;strong&gt;Decouple the proxy from the vendor.&lt;/strong&gt; By running an asynchronous proxy at the edge of your infrastructure that speaks native OpenTelemetry protocol (OTLP), your LLM telemetry becomes just another distributed trace in your existing observability stack.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-gateway-architecture&quot;&gt;2. Gateway Architecture&lt;/h3&gt;
&lt;p&gt;The proxy sits as a transparent, high-throughput intermediary between your internal applications (n8n instances, microservices, AI agents) and upstream model providers.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[ Client / Agent / n8n ] 
       │ (Bearer Token + Custom Tenant Headers)
       ▼
[ OTel Reverse Proxy Gateway (FastAPI / uvloop) ]
       ├── 1. Parse Context (Tenant, Workspace, Workflow ID)
       ├── 2. Stream Request to Upstream (OpenAI / Anthropic)
       ├── 3. Token Count &amp;amp; Cost Engine (Micro-cent resolution)
       └── 4. Build OpenTelemetry GenAI Span
       │
       ├───────────────┬─────────────────┐
       ▼               ▼                 ▼
[ OTLP / Tempo ]  [ ClickHouse ]  [ Upstream LLM ]
 (Distributed)     (Cost Analytics) (Returns Response)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;core-invariants&quot;&gt;Core Invariants&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Zero Overhead Streaming&lt;/strong&gt;: Responses are piped as &lt;code&gt;Transfer-Encoding: chunked&lt;/code&gt; byte streams to minimize Time-to-First-Token (TTFT) degradation (&amp;lt; 1.5ms added latency).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exact Cost Attribution&lt;/strong&gt;: Token calculations run against real-time model pricing tables, factoring in cached inputs, standard inputs, and completions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OTel GenAI Standard&lt;/strong&gt;: Complies with semantic convention attributes such as &lt;code&gt;gen_ai.system&lt;/code&gt;, &lt;code&gt;gen_ai.request.model&lt;/code&gt;, &lt;code&gt;gen_ai.usage.input_tokens&lt;/code&gt;, and &lt;code&gt;gen_ai.usage.cost&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-the-implementation-reverse-proxy-with-native-otlp-telemetry&quot;&gt;3. The Implementation: Reverse Proxy with Native OTLP Telemetry&lt;/h3&gt;
&lt;p&gt;Below is a working implementation using FastAPI, &lt;code&gt;httpx&lt;/code&gt;, and the OpenTelemetry Python SDK.&lt;/p&gt;
&lt;h4 id=&quot;step-1-pricing-engine-and-cost-matrix&quot;&gt;Step 1: Pricing Engine and Cost Matrix&lt;/h4&gt;
&lt;p&gt;Save this as &lt;code&gt;pricing.py&lt;/code&gt;. It tracks costs down to 6 decimal places per 1,000 tokens.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# pricing.py
from decimal import Decimal

MODEL_PRICING = {
    &quot;gpt-4o-mini&quot;: {
        &quot;input_per_1k&quot;: Decimal(&quot;0.00015&quot;),
        &quot;output_per_1k&quot;: Decimal(&quot;0.00060&quot;),
    },
    &quot;gpt-4o&quot;: {
        &quot;input_per_1k&quot;: Decimal(&quot;0.00500&quot;),
        &quot;output_per_1k&quot;: Decimal(&quot;0.01500&quot;),
    },
    &quot;claude-3-5-sonnet-20241022&quot;: {
        &quot;input_per_1k&quot;: Decimal(&quot;0.00300&quot;),
        &quot;output_per_1k&quot;: Decimal(&quot;0.01500&quot;),
    },
}

def calculate_cost(model: str, input_tokens: int, output_tokens: int) -&amp;gt; float:
    pricing = MODEL_PRICING.get(model, {&quot;input_per_1k&quot;: Decimal(&quot;0.0&quot;), &quot;output_per_1k&quot;: Decimal(&quot;0.0&quot;)})
    input_cost = (Decimal(input_tokens) / Decimal(1000)) * pricing[&quot;input_per_1k&quot;]
    output_cost = (Decimal(output_tokens) / Decimal(1000)) * pricing[&quot;output_per_1k&quot;]
    return float(input_cost + output_cost)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-2-proxy-server-with-otel-instrumentation&quot;&gt;Step 2: Proxy Server with OTel Instrumentation&lt;/h4&gt;
&lt;p&gt;Create &lt;code&gt;main.py&lt;/code&gt;. This service receives standard OpenAI-compatible completions, passes them upstream, counts stream tokens, and issues telemetry spans.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# main.py
import json
import time
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from pricing import calculate_cost

# Configure OpenTelemetry Provider
provider = TracerProvider()
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint=&quot;http://tempo:4317&quot;, insecure=True))
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(&quot;llm.observability.gateway&quot;, &quot;1.0.0&quot;)

app = FastAPI()
UPSTREAM_BASE = &quot;https://api.openai.com/v1&quot;

@app.post(&quot;/v1/chat/completions&quot;)
async def proxy_chat_completions(request: Request):
    start_time = time.time()
    body = await request.json()
    headers = dict(request.headers)
    
    # Extract Context Attribution
    tenant_id = headers.get(&quot;x-tenant-id&quot;, &quot;anonymous&quot;)
    workspace_id = headers.get(&quot;x-workspace-id&quot;, &quot;default&quot;)
    model = body.get(&quot;model&quot;, &quot;unknown&quot;)
    stream = body.get(&quot;stream&quot;, False)

    # Setup upstream client
    client = httpx.AsyncClient(base_url=UPSTREAM_BASE, timeout=60.0)
    auth_header = headers.get(&quot;authorization&quot;, &quot;&quot;)
    
    # Forward to Upstream
    upstream_req = client.build_request(
        method=&quot;POST&quot;,
        url=&quot;/chat/completions&quot;,
        headers={&quot;Authorization&quot;: auth_header, &quot;Content-Type&quot;: &quot;application/json&quot;},
        content=json.dumps(body)
    )
    upstream_resp = await client.send(upstream_req, stream=stream)

    if not stream:
        raw_data = await upstream_resp.aread()
        await client.aclose()
        data = json.loads(raw_data)
        
        # Extract token usage
        usage = data.get(&quot;usage&quot;, {})
        prompt_tokens = usage.get(&quot;prompt_tokens&quot;, 0)
        completion_tokens = usage.get(&quot;completion_tokens&quot;, 0)
        total_cost = calculate_cost(model, prompt_tokens, completion_tokens)
        
        # Record OTel Trace Span
        with tracer.start_as_current_span(&quot;gen_ai.chat&quot;) as span:
            span.set_attribute(&quot;gen_ai.system&quot;, &quot;openai&quot;)
            span.set_attribute(&quot;gen_ai.request.model&quot;, model)
            span.set_attribute(&quot;gen_ai.usage.input_tokens&quot;, prompt_tokens)
            span.set_attribute(&quot;gen_ai.usage.output_tokens&quot;, completion_tokens)
            span.set_attribute(&quot;gen_ai.usage.cost&quot;, total_cost)
            span.set_attribute(&quot;tenant.id&quot;, tenant_id)
            span.set_attribute(&quot;workspace.id&quot;, workspace_id)
            span.set_attribute(&quot;http.status_code&quot;, upstream_resp.status_code)
            span.set_attribute(&quot;latency.total_seconds&quot;, time.time() - start_time)

        return StreamingResponse(iter([raw_data]), status_code=upstream_resp.status_code, media_type=&quot;application/json&quot;)

    # Streaming logic
    async def stream_generator():
        completion_tokens = 0
        prompt_tokens = body.get(&quot;prompt_tokens_hint&quot;, 0)  # Heuristic fallback if not provided
        try:
            async for chunk in upstream_resp.aiter_bytes():
                # Track approximate completion chunks
                if b'&quot;content&quot;:' in chunk:
                    completion_tokens += 1
                yield chunk
        finally:
            await upstream_resp.aclose()
            await client.aclose()
            
            total_cost = calculate_cost(model, prompt_tokens, completion_tokens)
            
            with tracer.start_as_current_span(&quot;gen_ai.chat.stream&quot;) as span:
                span.set_attribute(&quot;gen_ai.system&quot;, &quot;openai&quot;)
                span.set_attribute(&quot;gen_ai.request.model&quot;, model)
                span.set_attribute(&quot;gen_ai.usage.input_tokens&quot;, prompt_tokens)
                span.set_attribute(&quot;gen_ai.usage.output_tokens&quot;, completion_tokens)
                span.set_attribute(&quot;gen_ai.usage.cost&quot;, total_cost)
                span.set_attribute(&quot;tenant.id&quot;, tenant_id)
                span.set_attribute(&quot;workspace.id&quot;, workspace_id)
                span.set_attribute(&quot;stream.chunks&quot;, completion_tokens)
                span.set_attribute(&quot;latency.total_seconds&quot;, time.time() - start_time)

    return StreamingResponse(stream_generator(), status_code=upstream_resp.status_code, media_type=&quot;text/event-stream&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-deployment-rate-limits-and-crm-billing-routing&quot;&gt;4. Deployment, Rate Limits, and CRM/Billing Routing&lt;/h3&gt;
&lt;h4 id=&quot;running-with-uvicorn-and-gunicorn&quot;&gt;Running with Uvicorn and Gunicorn&lt;/h4&gt;
&lt;p&gt;To run this under heavy multi-agent traffic (e.g., hundreds of concurrent n8n pipelines), deploy via Docker with &lt;code&gt;uvloop&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gunicorn main:app \
  --workers 4 \
  --worker-class uvicorn.workers.UvicornWorker \
  --bind 0.0.0.0:8080 \
  --backlog 2048 \
  --timeout 120
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;otlp-export-to-clickhouse-or-tempo&quot;&gt;OTLP Export to ClickHouse or Tempo&lt;/h4&gt;
&lt;p&gt;Configure standard environment variables for your container to target your backend without code changes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo.internal.net:4317
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
OTEL_SERVICE_NAME=production-llm-gateway
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;client-side-configuration-n8n-or-microservices-&quot;&gt;Client-Side Configuration (n8n or Microservices)&lt;/h4&gt;
&lt;p&gt;In your n8n workflows or LangChain services, point your client configuration to the self-hosted proxy instead of OpenAI directly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Base URL&lt;/strong&gt;: &lt;code&gt;http://proxy-gateway.internal:8080/v1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Headers&lt;/strong&gt;:&lt;ul&gt;
&lt;li&gt;&lt;code&gt;x-tenant-id&lt;/code&gt;: &lt;code&gt;customer-acme-corp&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;x-workspace-id&lt;/code&gt;: &lt;code&gt;prod-lead-enrichment-v2&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Your existing code requires &lt;strong&gt;zero architectural modifications&lt;/strong&gt;. It simply gains comprehensive tracing, cost tracking, and micro-cent billing attribution.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-conclusion-amp-ready-to-use-workflow-package&quot;&gt;5. Conclusion &amp;amp; Ready-to-Use Workflow Package&lt;/h3&gt;
&lt;p&gt;By deploying this self-hosted proxy, you instantly eliminate vendor telemetry lock-in and cut SaaS monitoring bills to zero. Every token processed by your workflows—whether triggered by n8n nodes, custom microservices, or autogen scripts—is tracked under unified OpenTelemetry standards.&lt;/p&gt;
&lt;p&gt;You can manually build and test this gateway using the Python code provided above.&lt;/p&gt;
&lt;p&gt;If you prefer a pre-built, production-hardened infrastructure package with &lt;strong&gt;Docker Compose topologies, Grafana dashboards, ClickHouse schema migrations, Redis rate limiting, and n8n verification workflows&lt;/strong&gt;, you can grab the complete turnkey distribution:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://whop.com/mr-studio-7faa/otel-native-llm-observability-cost-attribution-gateway&quot;&gt;Instant Access on Whop&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://mannyverse767.gumroad.com/l/xvjde&quot;&gt;Direct Download on Gumroad&lt;/a&gt;&lt;/strong&gt; (Use promo code &lt;strong&gt;&lt;code&gt;EARLYBIRD&lt;/code&gt;&lt;/strong&gt; for 20% off)&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/30423/stop-paying-0-05-per-trace-building-an-otel-native-llm-cost-attribution-gateway</guid>
<pubDate>Fri, 09 Oct 2026 09:49:24 +0000</pubDate>
</item>
<item>
<title>Putting the trace before the loop; Observability-first for an AI agent, and what it bought me</title>
<link>https://coderlegion.com/28284/putting-the-trace-before-the-loop-observability-first-for-an-ai-agent-and-what-it-bought-me</link>
<description>&lt;p&gt;&lt;em&gt;An observability-first approach for building an AI agent, and what it bought me.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A couple of weeks ago I started implementing Kept, a self-hostable post-purchase support agent for e-commerce, with reliability as its core offering. Besides the product itself, my objective in building it is to delve into the depths of agentic system design, and see what it actually means to build an agent with &quot;reliability at its core&quot;.&lt;/p&gt;
&lt;h3 id=&quot;observability-first&quot;&gt;Observability first&lt;/h3&gt;
&lt;p&gt;I started from a theory my experience validated again and again throughout the years: observability is the bedrock of reliability. Proper logs and metrics beat an ideal architecture, industry-leading frameworks or best coding patterns.&lt;/p&gt;
&lt;p&gt;That was the case well before the agentic era, and I've always paid particular attention to this layer. But with the advent of non-deterministic LLMs sitting at the core of products, I decided to take that to the next level: designing and implementing the full tracing layer before writing a single line of code for the main agent loop/product. Turned out to be an insightful experience.&lt;/p&gt;
&lt;p&gt;Up next I'll walk through how I designed and implemented the tracing layer, then present 3 benefits and 2 drawbacks that I consider worth highlighting from the observability-first approach, and end with some thoughts on how to apply the approach if you don't have the luxury of starting with a fresh codebase.&lt;/p&gt;
&lt;h3 id=&quot;tracing-layer-design-and-implementation&quot;&gt;Tracing layer design and implementation&lt;/h3&gt;
&lt;p&gt;I started with an AI-assisted research phase. Here's a list of resources I found particularly useful:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.anthropic.com/engineering/building-effective-agents&quot;&gt;Anthropic, &quot;Building Effective Agents&quot;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/blog/2026/genai-observability/&quot;&gt;OTel blog, &quot;Inside the LLM Call: GenAI Observability with OpenTelemetry&quot;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://john-hodge.com/blog/opentelemetry-genai-semantic-conventions/&quot;&gt;John Hodge, &quot;OpenTelemetry GenAI semantic conventions&quot;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/open-telemetry/semantic-conventions-genai&quot;&gt;OTel GenAI semantic conventions, the dedicated repo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://langfuse.com/docs/observability/data-model&quot;&gt;Langfuse data model&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;0-the-guiding-design-decision&quot;&gt;0. The guiding design decision&lt;/h4&gt;
&lt;p&gt;The research produced the first important insight. My original idea was to have Langfuse as the source of truth that saves all spans and traces and serves them wherever they're needed. But then I realized I'd have two consumers with very different needs. First we have the human, who needs to analyze, search and filter the traces in a well-structured UI. Then we have the machine (mostly evals) that needs fast access to a reliable record of what happened. Langfuse was the right choice for the human side, but having evals rely on it to get their traces meant additional API calls that added cost and latency, plus complex concurrency scenarios that needed to be accounted for.&lt;/p&gt;
&lt;p&gt;So I defined the first design decision in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/docs/decisions/0001-eval-assertions-read-the-in-process-trace.md&quot;&gt;ADR-0001&lt;/a&gt;, which states that &lt;strong&gt;Langfuse is a sink for humans, not a source for machines.&lt;/strong&gt; The practical consequence was an in-process model of the trace as the source of truth (which evals could use to run their checks), and Langfuse as a projection of it. This meant no Langfuse SDK and no OTel SDK in the core package, with a boundary-check script (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/scripts/check-eval-boundary.mjs&quot;&gt;check-eval-boundary.mjs&lt;/a&gt;) that enforced the ADR.&lt;/p&gt;
&lt;p&gt;The rest of the design/implementation process looked like this:&lt;/p&gt;
&lt;h4 id=&quot;1-deciding-the-shape-of-the-span-tree&quot;&gt;1. Deciding the shape of the span tree&lt;/h4&gt;
&lt;p&gt;I started sketching the shape of the span tree, using a self-test that drove every sketch round: can the types express &quot;a model call inside turn 2 followed by a tool span whose result state was unknown&quot;?&lt;/p&gt;
&lt;p&gt;After multiple rounds of sketches, I went from &quot;every model call is a span&quot; to three span kinds with a parent link: turn, model call, tool execution. The turn span would be the parent, and model calls would sit as siblings to tool executions, with causality carried by a &lt;code&gt;callId&lt;/code&gt;. This move also enabled a three-layer taxonomy:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Layer &lt;/th&gt;&lt;th&gt;Where it lives &lt;/th&gt;&lt;th&gt;Fields&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;What the customer saw &lt;/td&gt;&lt;td&gt;The turn span (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/types.ts#L84-L87&quot;&gt;types.ts#L84-L87&lt;/a&gt;) &lt;/td&gt;&lt;td&gt;&lt;code&gt;customerInput&lt;/code&gt; at start, &lt;code&gt;outcome&lt;/code&gt; at end. The outcome is a union: reply with message text, failed with a reason, conversation full.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;What the model saw &lt;/td&gt;&lt;td&gt;The model-call span (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/types.ts#L27-L39&quot;&gt;types.ts#L27-L39&lt;/a&gt;) &lt;/td&gt;&lt;td&gt;&lt;code&gt;inputMessages&lt;/code&gt; at start, &lt;code&gt;outputMessages&lt;/code&gt; at end. The assembled prompt, the model's reply including any tool-call requests, and tool responses as presented back to it.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;What actually happened &lt;/td&gt;&lt;td&gt;The tool-execution span (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/types.ts#L48-L54&quot;&gt;types.ts#L48-L54&lt;/a&gt;) &lt;/td&gt;&lt;td&gt;&lt;code&gt;toolName&lt;/code&gt;, &lt;code&gt;callId&lt;/code&gt;, &lt;code&gt;args&lt;/code&gt; at start. &lt;code&gt;resultState&lt;/code&gt; and &lt;code&gt;result&lt;/code&gt; at end. The args really executed and the state really returned.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;2-choosing-the-stamps-by-consumer&quot;&gt;2. Choosing the stamps by consumer&lt;/h4&gt;
&lt;p&gt;The next task was deciding what gets stamped on every trace and span (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/types.ts#L129-L144&quot;&gt;types.ts#L129-L144&lt;/a&gt;). Guided by the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/open-telemetry/semantic-conventions-genai&quot;&gt;OTel GenAI semantic conventions&lt;/a&gt;, I picked the stamps by asking who reads each one later. A few examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;promptHash&lt;/code&gt; for evals, hashing the template rather than the rendered prompt so the hash means &quot;prompt version&quot;;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;backendKind&lt;/code&gt; to localize a failure to adapter or core;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;traceId&lt;/code&gt; kept distinct from &lt;code&gt;sessionId&lt;/code&gt;, so evals can replay a conversation without merging the traces into one garbled tree;&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;faultToggles&lt;/code&gt; array in preparation for a later CI matrix: every trace declares which faults were injected when it ran, so a red in the matrix can be attributed to the toggle that caused it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;3-lifecycle-and-end-states&quot;&gt;3. Lifecycle and end states&lt;/h4&gt;
&lt;p&gt;A span starts as &lt;code&gt;in_progress&lt;/code&gt; while its corresponding process is running, and when the process finishes, it can end either in a &lt;code&gt;completed&lt;/code&gt; or &lt;code&gt;error&lt;/code&gt; state. If the span is still &lt;code&gt;in_progress&lt;/code&gt; when a trace is ended (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/trace.ts#L82-L91&quot;&gt;trace.ts#L82-L91&lt;/a&gt;), it's marked as &lt;code&gt;undetermined&lt;/code&gt; (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/span.ts#L92-L105&quot;&gt;span.ts#L92-L105&lt;/a&gt;), so a crash mid-span surfaces as a span rather than a missing one. &lt;code&gt;undetermined&lt;/code&gt; is deliberately not &lt;code&gt;error&lt;/code&gt;, because the step never reported failure, and not absent, because absence is indistinguishable from never started.&lt;/p&gt;
&lt;p&gt;Within the model-call span, there can be multiple &lt;code&gt;tool_call_response&lt;/code&gt; messages, each with its own status (&lt;code&gt;'ok' | 'failed' | 'unknown'&lt;/code&gt;), which is kept distinct from the span status. This way, the span status describes the step, while the tool result state describes the tool's answer, so a tool that cleanly reports failure is a completed span.&lt;/p&gt;
&lt;p&gt;A few more notes on the span lifecycle:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;for each span, first completion wins. A &lt;code&gt;completed&lt;/code&gt; span can't be later changed to &lt;code&gt;undetermined&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;trace end is idempotent and cached;&lt;/li&gt;
&lt;li&gt;starting a span after end (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/trace.ts#L34-L43&quot;&gt;trace.ts#L34-L43&lt;/a&gt;) is an error, thrown in dev and dropped in prod (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/trace.test.ts#L135-L144&quot;&gt;trace.test.ts#L135-L144&lt;/a&gt;);&lt;/li&gt;
&lt;li&gt;using the wall clock to mark the span start and a monotonic mark for duration.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;4-interfaces&quot;&gt;4. Interfaces&lt;/h4&gt;
&lt;p&gt;Up next was writing the types according to the conceptual design. Here's where I defined the shapes of the &lt;code&gt;ModelCallPayload&lt;/code&gt;, &lt;code&gt;ToolExecutionPayload&lt;/code&gt; and &lt;code&gt;TurnPayload&lt;/code&gt; as well as the actual &lt;code&gt;TracePayload&lt;/code&gt;. And I also defined the &lt;code&gt;Message&lt;/code&gt; type and the discriminated union of &lt;code&gt;MessagePart&lt;/code&gt;s it can contain (&lt;code&gt;'text'&lt;/code&gt;, &lt;code&gt;'tool_call'&lt;/code&gt; or &lt;code&gt;'tool_call_response'&lt;/code&gt;); these types ended up hoisted outside the tracing module since they represent a core part of the domain. More on that in the next article.&lt;/p&gt;
&lt;p&gt;You can view the types and their annotations in these files: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/types.ts&quot;&gt;types.ts&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/messages.ts&quot;&gt;messages.ts&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id=&quot;5-classes&quot;&gt;5. Classes&lt;/h4&gt;
&lt;p&gt;This step was mostly about the design surviving the compiler. I implemented the spans as a generic, abstract &lt;code&gt;SpanBase&lt;/code&gt; class with its &lt;code&gt;end()&lt;/code&gt;, &lt;code&gt;error()&lt;/code&gt; and &lt;code&gt;abandonIfInProgress()&lt;/code&gt; methods corresponding to the end states I mentioned in the lifecycle design, plus some helpers. Then came the &lt;code&gt;Trace&lt;/code&gt; class that was responsible for starting the spans, keeping them in a &lt;code&gt;spans&lt;/code&gt; array and sweeping the unfinished ones in &lt;code&gt;trace.end()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A noteworthy implementation detail was deriving &lt;code&gt;Start...&lt;/code&gt; and &lt;code&gt;End...&lt;/code&gt; payloads that corresponded to a span's constructor payload and its &lt;code&gt;end()&lt;/code&gt; method, respectively. This was particularly useful to make the TypeScript compiler a friend and not a foe: the partition rule (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/types.ts#L12-L15&quot;&gt;types.ts#L12-L15&lt;/a&gt;), the model-call partition (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/types.ts#L41-L46&quot;&gt;types.ts#L41-L46&lt;/a&gt;), the generic base whose constraint is the invariant (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/span.ts#L36-L41&quot;&gt;span.ts#L36-L41&lt;/a&gt;), and the subclass where it is enforced (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/span.ts#L108-L112&quot;&gt;span.ts#L108-L112&lt;/a&gt;).&lt;/p&gt;
&lt;h4 id=&quot;6-trace-exporter-as-four-pieces&quot;&gt;6. Trace exporter as four pieces&lt;/h4&gt;
&lt;p&gt;By this point, I had the first part of ADR-0001 done: the in-process model of the trace, ready to be used by evals and testing harnesses. What was left was the exporter that packaged the in-process/in-memory model and sent it over to Langfuse. I implemented it in four parts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a map holding the OTel GenAI attributes (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/export/otel-attributes.ts&quot;&gt;otel-attributes.ts&lt;/a&gt;) I picked for the traces and spans (for example, &lt;code&gt;sessionId&lt;/code&gt; as &lt;code&gt;'gen_ai.conversation.id'&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;an OTLP mapper (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/export/otlp.ts#L245-L270&quot;&gt;otlp.ts#L245-L270&lt;/a&gt;) that maps the captured attributes from their internal implementation (&lt;code&gt;modelCallSpan.model&lt;/code&gt;) to the corresponding OTel GenAI semantic-convention name (&lt;code&gt;'gen_ai.request.model'&lt;/code&gt;), and wraps them into an OTLP envelope to be sent over the network&lt;/li&gt;
&lt;li&gt;a generic interface for the exporter (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/export/exporter.ts#L3-L21&quot;&gt;exporter.ts#L3-L21&lt;/a&gt;) that contained:&lt;ul&gt;
&lt;li&gt;an &lt;code&gt;export(input: CompletedTrace)&lt;/code&gt; function that takes a completed trace, uses the mapper to turn it into an OTLP envelope and fires the transport call, saving the promise for &lt;code&gt;flush()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;flush()&lt;/code&gt; function that awaits all pending exports&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;the Langfuse implementation of the generic interface (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/export/langfuse.ts#L50-L101&quot;&gt;langfuse.ts#L50-L101&lt;/a&gt;). The export layer is the only place where we use Langfuse-specific lexicon, spread across the attribute map, the OTLP mapper and the transport itself. Since I already had the attributes following the OTel GenAI convention, I decided to send them to Langfuse via a simple &lt;code&gt;POST&lt;/code&gt; request, skipping any extra dependencies&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;7-verifying-with-a-fabricated-conversation&quot;&gt;7. Verifying with a fabricated conversation&lt;/h4&gt;
&lt;p&gt;Since no agent existed at this point, I had to implement a script (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/scripts/trace-demo.ts&quot;&gt;trace-demo.ts&lt;/a&gt;) that created a fake WISMO (&quot;where is my order&quot;) turn so I could see it in the Langfuse dashboard. I also left a span deliberately open (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/scripts/trace-demo.ts#L108-L117&quot;&gt;trace-demo.ts#L108-L117&lt;/a&gt;), so I could watch the trace sweep in action.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/wpafncapsnzmobxrzmdi.png&quot; alt=&quot;The Langfuse tracing dashboard showing the fabricated conversation: six observations across two turn spans, three model calls and one lookup_order tool span, with the input, output and promptHash columns filled in.&quot;&gt;&lt;/p&gt;
&lt;p&gt;At this point I discovered a bug that actually made me happy: Langfuse was rendering &quot;empty object&quot; as a tool call argument, while &lt;code&gt;args&lt;/code&gt; was populated in the in-process trace. While the fix (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/export/otlp.ts#L101-L125&quot;&gt;otlp.ts#L101-L125&lt;/a&gt;) was pretty simple (the attribute mapping was incorrect), this bug proved the utility of ADR-0001 defined at step 0. Had evals been reading Langfuse, an assertion like &quot;the tool was called with order 1042&quot; would have gone red, looking exactly like a policy bug, when the only defect was a key name in the mapper. Because evals were designed to read the in-process object, that class of failure cannot produce a false red.&lt;/p&gt;
&lt;p&gt;With the clear split between the sources, I could implement tests that touched the core functionality of the agent, and handle transport/presentational errors separately (those still need to be tested, of course).&lt;/p&gt;
&lt;p&gt;After a few more fixes and design updates, the tracing layer was done and I was ready to implement the main agent loop. More on that in the next article.&lt;/p&gt;
&lt;h3 id=&quot;tradeoffs&quot;&gt;Tradeoffs&lt;/h3&gt;
&lt;p&gt;As I said in the beginning, designing the full observability layer before writing a single line of product code was a novel approach for me. The process ended up taking more time than I expected, but it was quite insightful. Here are a few benefits and drawbacks of the approach that I find valuable.&lt;/p&gt;
&lt;h4 id=&quot;benefit-1-you-ask-the-production-questions-on-day-one&quot;&gt;Benefit #1: you ask the production questions on day one&lt;/h4&gt;
&lt;p&gt;The first benefit of this approach was having to visualize the entire user-system-model-tools interaction from the perspective of the dashboard I'll stare at when debugging the ugliest bugs.&lt;/p&gt;
&lt;p&gt;I had to ask myself questions that would normally come up only when the product misbehaved in production:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;what logs would be helpful when trying to understand what the model did?&lt;/li&gt;
&lt;li&gt;how would I search these logs?&lt;/li&gt;
&lt;li&gt;what attributes would I filter by?&lt;/li&gt;
&lt;li&gt;how would I know what state a conversation is in, and how it got there?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Besides the basic questions, edge cases came up, too. And thinking about them from the perspective of how I'll debug them was particularly useful:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a tool span never completes, but the trace reaches its end. What does the trace show?&lt;ul&gt;
&lt;li&gt;that question is where the undetermined state in step 3 came from: in production, the unfinished span stays in the tree with a state that says &quot;nobody knows&quot;. In development, it throws so I can fix the lifecycle bug.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;the eval runner replays the same conversation three times. What do I see?&lt;ul&gt;
&lt;li&gt;one garbled tree, if the trace id doubles as the conversation id, because any OTel backend merges every span that shares a trace id. That is the reason step 2 keeps &lt;code&gt;traceId&lt;/code&gt; distinct from the session id: the recorder mints a fresh trace id per run, while the session id stays a correlation key owned by the outside world, so three replays render as three traces inside one session.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;a policy check changes the args between what the model requested and what got executed. Which one does the trace record?&lt;ul&gt;
&lt;li&gt;both, in different places: the model's request stays in the model-call span's &lt;code&gt;outputMessages&lt;/code&gt;, while the args that actually ran go on the tool-execution span. That's exactly the purpose of the three-layer split from the step 1 table. The layers are allowed to disagree by design.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;benefit-2-no-existing-constraints-means-best-practices-become-applicable&quot;&gt;Benefit #2: no existing constraints means best practices become applicable&lt;/h4&gt;
&lt;p&gt;As I described in the beginning, I started the design phase with research on best practices and novel design/implementation patterns. Pretty standard approach. But this time it felt different.&lt;/p&gt;
&lt;p&gt;Previously, when I had to implement an observability layer, I always had to start from an existing product or codebase. That became the toughest set of constraints that ended up dictating the design of the entire layer.&lt;/p&gt;
&lt;p&gt;Practical example from Kept: a vendor-neutral trace model, with the vendor confined to the exporter. Try declaring your own plain-data trace the source of truth when you have fifteen services already instrumented with the vendor SDK's decorators; half of them are in Python; and there's a dashboard where three teams filter by attribute names nobody wrote down. You can already feel the headaches building up. But then try it in a fresh codebase: it is one ADR (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/docs/decisions/0001-eval-assertions-read-the-in-process-trace.md&quot;&gt;ADR-0001&lt;/a&gt;), one types file (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/types.ts&quot;&gt;types.ts&lt;/a&gt;), a 45-line attribute dictionary (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/export/otel-attributes.ts&quot;&gt;otel-attributes.ts&lt;/a&gt;), 111 lines for the exporter (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/packages/core/src/tracing/export/langfuse.ts&quot;&gt;langfuse.ts&lt;/a&gt;), and a boundary script (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dragosbln/kept/blob/v0.1.0/scripts/check-eval-boundary.mjs&quot;&gt;check-eval-boundary.mjs&lt;/a&gt;) that fails the build if anyone imports the SDK into core. Easy as &lt;code&gt;pnpm check&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Researching best practices is not very useful when the way your product/codebase is implemented makes it too expensive to adopt them. I realized I didn't have that problem. Having no existing constraints makes the best pattern cheap to adopt.&lt;/p&gt;
&lt;h4 id=&quot;benefit-3-you-get-a-first-sketch-of-your-architecture-for-free&quot;&gt;Benefit #3: you get a first sketch of your architecture for free&lt;/h4&gt;
&lt;p&gt;So I answered the questions and edge cases from the observability-first angle. I researched best practices and I implemented them in my greenfield tracing layer. I hooked up the self-hosted Langfuse instance and wrote some throwaway scripts to populate the dashboard. Fixed the bugs that came up, too.&lt;/p&gt;
&lt;p&gt;All looked good, so I turned my attention to the agent loop. And here's where I got a very pleasant surprise. The loop design was already halfway done:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the span tree was the loop's control flow. One turn span per invocation, one model-call span per round, one tool span per execution, and the turn's end payload as the return value.&lt;/li&gt;
&lt;li&gt;the end-turn payload makes &lt;code&gt;outcome&lt;/code&gt; required, so every exit path had to have one. Enumerating the exits turned the single &lt;code&gt;reply&lt;/code&gt; outcome into &lt;code&gt;reply | failed(reason) | conversation_full&lt;/code&gt;, and the failure reasons became the loop's terminal states: max rounds, refusal, max tokens, unknown stop reason, empty reply. The state machine was the trace type, filled in.&lt;/li&gt;
&lt;li&gt;the tool span's end payload became the tool contract. Closing a tool span takes &lt;code&gt;resultState&lt;/code&gt; and &lt;code&gt;result&lt;/code&gt;, so the &lt;code&gt;ToolResult&lt;/code&gt; contract adopted those exact names, plus a separate &lt;code&gt;response&lt;/code&gt; for the text the model sees.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We all know how hard it is to start with a blank canvas. For me, the first line takes about as much mental effort as the rest of the diagram combined. Where do I start? How do I structure it? How do I avoid a foundation that locks me into the wrong pattern?&lt;/p&gt;
&lt;p&gt;The observability-first approach saved me from that trouble and gave me a clear starting point. And by the nature of the exercise, I started with edge cases/bugs first, as opposed to the happy-path approach I'd normally take, which increased my confidence in the foundation even more.&lt;/p&gt;
&lt;h4 id=&quot;drawback-1-you-ll-have-to-revise-the-tracing-layer-later&quot;&gt;Drawback #1: you'll have to revise the tracing layer later&lt;/h4&gt;
&lt;p&gt;While asking debugging-oriented questions at the beginning and trying to picture edge cases is an insightful exercise, you won't cover every angle from the start. Naturally, some details will show up only as you're implementing the agent and they will require modifying the tracing layer.&lt;/p&gt;
&lt;p&gt;You can argue the tracing layer is more stable when you place it over a codebase that's already live and working, and there's truth to that: you know what the system actually does, instead of trying to picture it upfront. Some of that stability can also come from constraints that make changes expensive. Still, the retrofit can cost less redesign time, and that is a real tradeoff of this approach.&lt;/p&gt;
&lt;h4 id=&quot;drawback-2-throwaway-scripts&quot;&gt;Drawback #2: throwaway scripts&lt;/h4&gt;
&lt;p&gt;Since you don't have a product in place, you'll need to put some extra work in scripts that simulate real traffic to see what your dashboard will actually look like; and those scripts might not reflect reality as it will present itself in production. You'll end up with code you have to throw away, and if you're not careful, the picture painted by the scripts can be misleading in important ways.&lt;/p&gt;
&lt;h3 id=&quot;what-if-i-don-t-have-the-luxury-of-working-with-a-fresh-codebase-&quot;&gt;What if I don't have the luxury of working with a fresh codebase?&lt;/h3&gt;
&lt;p&gt;Initially I wanted to list the &quot;fresh codebase&quot; precondition as a tradeoff, which it is. The approach obviously works best when there's no code and no constraints in place. But I think the insights can be applied to an existing codebase, too. Here are some ideas:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Go trace-first on every new AI feature.&lt;/strong&gt; Before writing the feature, write its trace design: which steps become spans, which attributes you will need when it misbehaves, what a crash halfway through should look like. Then write the code to satisfy the trace. The feature boundary is a small greenfield, granted, it needs to be compatible with the system currently in place; and if you find ways to isolate it, all the better!&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Go trace-first after an incident.&lt;/strong&gt; Same idea from a different angle: instead of starting from a roadmap, prioritize by real pain. Once the immediate problem is mitigated, design the trace that would have made the diagnosis take five minutes, ship that trace, and use it to understand the failure and verify the permanent fix.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Design the ideal tracing layer as if the codebase didn't exist.&lt;/strong&gt; Apply the unconstrained research and design step as an exercise. Sketch what one request should look like in the dashboard, ignoring what the code can currently emit, then diff that against what you actually see. You end up with a backlog pre-ordered by debugging value rather than by what is easy. As a bonus, you skip drawback #2 (inaccurate throwaway scripts), since you already have production data to work with.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The obvious caveat here is that Benefit #2 loses its relevance, unless you find a way to fully isolate a new feature from the existing implementation/system.&lt;/p&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Observability-first is not a novel idea; observability-driven development has been argued for since the late 2010s. In practice, though, tracing is usually deferred as premature in the rush to ship an MVP and learn from customers. Those practices have earned their place. But with non-deterministic language models sitting at the core of a growing class of products, I'd argue observability deserves to move from afterthought to first step.&lt;/p&gt;
&lt;p&gt;How would you iterate on your AI product if you knew what the model did at every step? How would you trust your evals if, instead of judging an output, they looked at the whole picture? How would you respond to incidents? How would you feel about telling your stakeholders &quot;you can trust this product&quot;?&lt;/p&gt;
&lt;p&gt;Would it be worth sacrificing fast early progress in favor of some extra time to think about observability?&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28284/putting-the-trace-before-the-loop-observability-first-for-an-ai-agent-and-what-it-bought-me</guid>
<pubDate>Wed, 23 Sep 2026 08:38:45 +0000</pubDate>
</item>
<item>
<title>I pentested my own AI hub and shipped the method, not the map.</title>
<link>https://coderlegion.com/25338/i-pentested-my-own-ai-hub-and-shipped-the-method-not-the-map</link>
<description>&lt;p&gt;I ran a penetration test on my own infrastructure last week. No Burp Suite, no exploit fired at production, no CVE popped. The whole engagement came down to one habit: refusing to believe a control was working until I had watched it work.&lt;/p&gt;
&lt;p&gt;The target is a small observability hub I built for my own AI-assisted coding. Six services in one compose file: a tunnel, an OpenTelemetry Collector taking metrics and logs from Claude Code, Prometheus, Grafana, Loki, and a status API. The public surface is three aggregate numbers. Everything else stays private. That boundary, three numbers out and nothing else, was the whole thing I was testing.&lt;/p&gt;
&lt;p&gt;The word &quot;pentest&quot; carries a picture that does not match, so: no attack traffic at the live system. The platform bills by usage and there is a WAF in front, so a flood of probes would have cost money and poisoned its own results. What I did was a read-only audit of the code and config, plus a dynamic run against the whole stack brought up locally in Docker.&lt;/p&gt;
&lt;p&gt;I expected the findings to cluster around the parts nobody had looked at. They did the opposite. Nearly every serious defect sat inside a control written days or hours earlier, usually by me, usually with a comment beside it naming what it protected against. Old code has been observed: it has run against real traffic and somebody has been surprised by it. A defence written yesterday has only been reasoned about, which feels like the same thing and is not.&lt;/p&gt;
&lt;h3 id=&quot;-independent-is-a-measurement-not-a-comment&quot;&gt;&quot;Independent&quot; is a measurement, not a comment&lt;/h3&gt;
&lt;p&gt;The privacy boundary is an allow-list rather than a deny-list, and that part was right. Claude Code was measured sending five identity attributes, &lt;code&gt;user.email&lt;/code&gt; among them carrying a real address, and no flag turns them off. A &lt;code&gt;delete_key&lt;/code&gt; for each works until the client adds a sixth, and this telemetry is beta: its attribute set is not a contract.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- context: resource
  statements:
    - keep_keys(resource.attributes, [&quot;service.name&quot;])
    - set(resource.attributes[&quot;service.name&quot;], &quot;claude-code&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second line is not redundant: &lt;code&gt;keep_keys&lt;/code&gt; filters keys, not values, and &lt;code&gt;service.name&lt;/code&gt; is the one attribute that becomes an index label in Loki. On 2026-08-20 a sender holding the ingest token wrote it as &lt;code&gt;claude-code-…victim@example.com&lt;/code&gt;, and the address arrived as an index label.&lt;/p&gt;
&lt;p&gt;What was wrong was a sentence in my design note calling the two barriers on that boundary independent. One is in the Collector, one in Loki, which re-filters whatever reaches it. &lt;code&gt;keep_keys(log.attributes, …)&lt;/code&gt; governs record attributes, and Loki's &lt;code&gt;otlp_config&lt;/code&gt; has three sections, all three of attributes. Scope attributes crossed both untouched: remove Loki's list to test the isolation and a planted &lt;code&gt;scope.secret&lt;/code&gt; was suddenly queryable, while identity and content stayed out. The repair reads like a no-op and is the whole fix:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- context: scope
  statements:
    - keep_keys(scope.attributes, [])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What should have warned me is that the same hole existed twice. Two days later the metrics path turned out to be leaking scope attributes as &lt;code&gt;otel_scope_*&lt;/code&gt; labels past any allow-list, while the comment beside that exporter declared the boundary closed.&lt;/p&gt;
&lt;h3 id=&quot;a-proof-that-executes-is-not-a-proof-that-exercises&quot;&gt;A proof that executes is not a proof that exercises&lt;/h3&gt;
&lt;p&gt;A line in the log path was meant to zero the trace ID on every record before storage. Written correctly, in the right place, covered by a proof that ran it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# looks right, fails on every record
- set(log.trace_id.string, &quot;&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ParseTraceID&lt;/code&gt; wants 32 hex characters and the empty string is not one, so the statement failed on every record, the Collector logged &lt;code&gt;failed to execute statement&lt;/code&gt; and carried on, and the field reached Loki untouched. Measured on 2026-08-21 against the first real traffic: two warnings per record, around 180 per session, and a barrier that was declared and absent. The proof could not see it, because the payload never carried a trace ID. Green, and blind.&lt;/p&gt;
&lt;p&gt;Its twin is worse, because there the failure was conditional. OTTL documents that &lt;code&gt;set&lt;/code&gt; does nothing at all if the value resolves to nil, so a line collapsing the log body to the event name did nothing on any record without an &lt;code&gt;event.name&lt;/code&gt;, and a body containing a prompt and an address arrived in Loki verbatim. Neither proof could have caught it: the client always sends &lt;code&gt;event.name&lt;/code&gt;, and the synthetic payload had to include it to satisfy a different assertion. The defence was a no-op in exactly the case it existed for. Both close the same way, by making the payload carry the thing.&lt;/p&gt;
&lt;h3 id=&quot;the-worst-one-was-inside-the-proofs&quot;&gt;The worst one was inside the proofs&lt;/h3&gt;
&lt;p&gt;Those shell proofs are the instrument this project uses to not have silent failures. During the audit I found a silent failure inside the instrument, six hours old and mine.&lt;/p&gt;
&lt;p&gt;Two of them pinned the Collector image literally, &lt;code&gt;0.158.0&lt;/code&gt;, under a comment claiming it was the same digest as production. A dependency PR had moved compose and the Railway Dockerfile to &lt;code&gt;0.159.0&lt;/code&gt;, Dependabot does not read shell, and the proofs went on pulling the old image and passing. So the sentence I had written to verify that upgrade, &quot;contract proof green on the new image&quot;, was false. The pin is no longer copied: it is read out of &lt;code&gt;docker-compose.yml&lt;/code&gt;, and each proof prints the image it is running on.&lt;/p&gt;
&lt;p&gt;Then I wrote a CI gate so it could not happen again, and an adversarial review found the gate born broken. It counted how many proofs derive their image by searching the whole file for the string &lt;code&gt;docker-compose.yml&lt;/code&gt;, comments included, so the comment describing the derivation survived the derivation: delete the real line and the count stayed at three and the gate stayed green. Fifteen lines below a comment forbidding exactly that pattern, in a file where the same mistake had already been made three times.&lt;/p&gt;
&lt;p&gt;The shape has siblings. A blocking image scan went green because uninstalling pip is not removing it: &lt;code&gt;ensurepip/_bundled/&lt;/code&gt; keeps a second copy as a wheel, and the scanner does not read inside an archive. Each of those looks like a pass. None is evidence of the property you wanted.&lt;/p&gt;
&lt;h3 id=&quot;the-dynamic-run-and-what-it-does-not-prove&quot;&gt;The dynamic run, and what it does not prove&lt;/h3&gt;
&lt;p&gt;I brought the metrics path up locally with fake secrets and pushed one metric carrying identity with a valid token: an email in &lt;code&gt;user.email&lt;/code&gt;, an id in &lt;code&gt;organization.id&lt;/code&gt;, and a hostile value inside &lt;code&gt;service.name&lt;/code&gt; itself.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;# auth first, before anything else gets a vote
no auth      -&amp;gt; 401
wrong token  -&amp;gt; 401
valid token  -&amp;gt; 200   # accepted, now let's see what survives it
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then read the exporter. Here is the single series it exposes, in full:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;claude_code_token_usage{job=&quot;claude-code&quot;,model=&quot;claude-opus-5&quot;,session_id=&quot;sess-canary&quot;,type=&quot;input&quot;} 4242
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The email is gone, the organization id is gone, the hostile &lt;code&gt;service.name&lt;/code&gt; was pinned instead of becoming a label. What stayed are the three keys I allowed.&lt;/p&gt;
&lt;p&gt;And &lt;code&gt;4242&lt;/code&gt; went all the way to the public numbers, which is the honest half. An allow-list of names does not constrain values: whoever holds the ingest token can write &lt;code&gt;claude_code.token.usage&lt;/code&gt; with any number in it, and the public queries read that counter with &lt;code&gt;max_over_time(…[25h])&lt;/code&gt;, so an injected spike stays stuck for twenty-five hours. Measured on a test stack: &lt;code&gt;1e12&lt;/code&gt; tokens. It does not close here, because the token identifies the trusted producer and these numbers have no second source. What does close is the number arriving from somebody else: the three public queries now carry &lt;code&gt;{job=&quot;otel-collector&quot;}&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The payload is also synthetic, so it proves the allow-list discards what I hand it, not that the client only sends that. On this same project a synthetic payload has already confirmed a query and then lied to me.&lt;/p&gt;
&lt;h3 id=&quot;what-i-did-not-publish-and-why&quot;&gt;What I did not publish, and why&lt;/h3&gt;
&lt;p&gt;There is a version of this post that lists every residual weakness in the running system by name, with the exact route and the exact window. I wrote that report. It stays in the drawer.&lt;/p&gt;
&lt;p&gt;The obvious objection is that the repository is public, so what am I withholding. The answer is the aggregation. Every defect above is closed in the open, with the measurement that found it sitting in the commit that fixed it. A list of what is still open, in one place, with the routes and the timings next to each other, is a different object. It is not a disclosure, it is directions.&lt;/p&gt;
&lt;h3 id=&quot;worth-checking-on-yours&quot;&gt;Worth checking on yours&lt;/h3&gt;
&lt;p&gt;If you send telemetry from an AI coding client, read one raw record before you read your config. Identity ships by default in this class of product, and every deny-list you write today is a list of the fields that existed this morning.&lt;/p&gt;
&lt;p&gt;If you lean on two barriers, the useful question is not whether both are configured. It is which set of data only one of them is actually seeing. Break each on purpose and query the other back.&lt;/p&gt;
&lt;p&gt;And if you have proofs, ask what your last three green runs actually ran against. Mine were pulling an image production had already left behind, and they told me so in the friendliest way available: by passing.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25338/i-pentested-my-own-ai-hub-and-shipped-the-method-not-the-map</guid>
<pubDate>Tue, 25 Aug 2026 08:07:08 +0000</pubDate>
</item>
<item>
<title>Why your LLM and agent tracing should be OpenTelemetry-native</title>
<link>https://coderlegion.com/21357/why-your-llm-and-agent-tracing-should-be-opentelemetry-native</link>
<description>&lt;p&gt;A trace is one of the more useful things you can capture from an LLM app, and many teams store it in a format they cannot move later. If your AI tracing uses its own private format, the spans sit in a silo, a backend change means re-instrumenting, and the AI traces never join the HTTP and database traces under one request. Making tracing OpenTelemetry-native from the start avoids that.&lt;/p&gt;
&lt;p&gt;It helps to start with what traces give you that logs do not. The failures that matter most in an LLM app often do not throw an error. An agent returns a confident but wrong answer, nothing errors, and the log says the request finished in 4.2 seconds with a 200.&lt;/p&gt;
&lt;p&gt;A trace shows the same request as a tree: an LLM call that planned three tool calls, a tool call whose return was an empty array, and a second LLM call that took that empty array and answered anyway. The trace shows the problem because it records the input and output of every step, not just that the step ran.&lt;/p&gt;
&lt;p&gt;A log line for a tool call is a timestamp and a status. A tool span is the tool name, the arguments passed, the return payload, and the latency, all queryable, so you can filter to every empty tool return across a week of traffic.&lt;/p&gt;
&lt;p&gt;That only works if the tracing layer models what an agent actually does. A generic OpenTelemetry install knows about HTTP requests and database queries, but it has no concept of an LLM call, a retrieval step, a tool invocation, or an agent that loops. A tracing layer for AI apps needs five span kinds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LLM spans:&lt;/strong&gt; model, provider, input and output, token counts, temperature.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retriever spans:&lt;/strong&gt; the query, the retrieved chunks, the embedding model, top-k. A wrong RAG answer usually comes from retrieval, not the model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tool spans:&lt;/strong&gt; the tool name, the JSON arguments, the return payload. Most silent agent failures show up here.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agent spans:&lt;/strong&gt; the parent wrapping one agent's turn, so a multi-agent system shows which agent did what.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Chain spans:&lt;/strong&gt; one per multi-step sequence, giving the per-step view a single root span buries.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When an answer is wrong, you read the tree from the bottom: was the final LLM span ungrounded, did a tool return something empty, did the retriever pull the wrong chunks. The span kinds turn &quot;the agent is broken&quot; into a specific failing node.&lt;/p&gt;
&lt;p&gt;The thing worth getting right early is the trace format itself. A tracing tool tied to a single AI-specific dialect works until the rest of your stack runs on OpenTelemetry and the AI spans cannot join it. OpenTelemetry-native tracing keeps the spans portable, so a backend change moves your data with you instead of leaving it behind. It puts your LLM and tool spans under the same trace ID as the HTTP and database spans your services already emit, so a slow request is one tree instead of two dashboards. And it exports over OTLP, the wire format your collector already handles, with no separate ingest path.&lt;/p&gt;
&lt;p&gt;The library I use for this is traceAI: Apache 2.0, built on the OpenTelemetry SDK for Python and TypeScript, with the five span kinds above and 30+ provider and framework integrations, so for most stacks the instrumentation is an install and one register call.&lt;/p&gt;
&lt;p&gt;When you compare tracing layers, four questions cover it:&lt;/p&gt;
&lt;h4 id=&quot;does-it-cover-the-span-kinds-your-app-produces-&quot;&gt;Does it cover the span kinds your app produces?&lt;/h4&gt;
&lt;p&gt;A chat wrapper needs LLM spans. A RAG app needs retriever spans too. An agent needs tool and agent spans. A multi-step pipeline needs chain spans. A tracing layer that only models LLM calls leaves you blind on the steps that actually fail. traceAI covers all five. Check this first, because no amount of backend polish fixes a missing span kind.&lt;/p&gt;
&lt;h4 id=&quot;does-it-integrate-with-your-frameworks-&quot;&gt;Does it integrate with your frameworks?&lt;/h4&gt;
&lt;p&gt;Count the providers and frameworks in your stack, then check how many the library ships an integration for. Every gap is instrumentation you write and maintain by hand. TraceAI's 30+ integrations cover the common Python and TypeScript stacks; if yours is exotic, confirm the hand-emit path works for you.&lt;/p&gt;
&lt;h4 id=&quot;how-deep-is-the-backend-&quot;&gt;How deep is the backend?&lt;/h4&gt;
&lt;p&gt;A trace tree you can look at is the floor. The useful question is what runs on top: can the backend score spans for correctness, cluster failures into named issues, attach guardrails inline, and feed an optimization loop. Tracing tells you what happened. The backend is where you find out whether it was right and what to do about it.&lt;/p&gt;
&lt;h4 id=&quot;what-is-the-license-and-is-the-instrumentation-independent-of-the-platform-&quot;&gt;What is the license, and is the instrumentation independent of the platform?&lt;/h4&gt;
&lt;p&gt;An Apache 2.0 instrumentation layer built on open OpenTelemetry means you can self-host, modify, and avoid a per-seat fee on the tracing itself. traceAI is Apache 2.0, and the instrumentation runs independently of the FAGI platform; point the exporter at a self-hosted OTel backend and no span leaves your infrastructure.&lt;/p&gt;
&lt;p&gt;Once the spans land, the trace becomes the input to the rest of the loop. An evaluation layer scores each span for things like groundedness, context relevance, and task completion, and the scores attach back onto the spans so a failing one is filterable. The low-scoring spans get clustered into named failure modes, each with a likely root cause and a fix to try.&lt;/p&gt;
&lt;p&gt;From there an optimizer acts on those failures: agent-opt ships six prompt optimizers that run against your eval results, and every run is human-initiated and gated by an evaluator rather than an automatic rewrite. The order is simple: traces produce the spans, evaluation scores them, clustering groups the failures, and optimization addresses them.&lt;/p&gt;
&lt;p&gt;The trace format is the part that is hard to change later, so it is the one to settle first. The instrumentation can stay open and OpenTelemetry-native regardless of which backend you end up on, and the backend is a separate choice based on what it does with the trace once it lands.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21357/why-your-llm-and-agent-tracing-should-be-opentelemetry-native</guid>
<pubDate>Thu, 25 Jun 2026 09:53:17 +0000</pubDate>
</item>
<item>
<title>OpenTelemetry-native, all-in-one, simple</title>
<link>https://coderlegion.com/20644/opentelemetry-native-all-in-one-simple</link>
<description>&lt;p&gt;&lt;strong&gt;The standard observability stack:&lt;/strong&gt;&lt;br&gt;
Grafana + Loki + Tempo + Prometheus. Four services to deploy, four configs to learn, dashboards to set up before you see a single trace.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What I actually needed:&lt;/strong&gt;&lt;br&gt;
One place to see traces, logs, and metrics from my services.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What I built:&lt;/strong&gt;&lt;br&gt;
One container. &lt;code&gt;docker compose up&lt;/code&gt;. Done.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/awtklcqp2tqobs8d4t1w.png&quot; alt=&quot;Dasboard Overview&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;I work with .NET microservices and at some point I went through the full Grafana setup. And look, it works fine, it's a solid stack. But for what I needed it was way too much. I spent more time configuring things than actually looking at my data. The .NET Aspire dashboard is on the other end: you get a nice UI, but nothing is persisted. Close the browser and everything's gone. No customization either.&lt;/p&gt;
&lt;p&gt;So I built something in between.&lt;/p&gt;
&lt;h3 id=&quot;what-it-is&quot;&gt;What it is&lt;/h3&gt;
&lt;p&gt;OtlpDashboard is a single container that receives OpenTelemetry data and stores it. It speaks standard OTLP on port 4317 (gRPC) and 4318 (HTTP/Protobuf). If your app already exports telemetry, you just point it here. No collector needed (works behind one too if you already have it).&lt;/p&gt;
&lt;p&gt;Storage is SQLite by default, so zero config, just a file. You can switch to PostgreSQL or SQL Server if you want.&lt;/p&gt;
&lt;p&gt;The frontend is a Vue 3 / Nuxt 4 SPA served on the same port. You get a grid of widgets you can customize: stat cards, line charts, sparklines, gauges, heatmaps, log streams, trace viewers, and a few more. Auth is on by default.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bohqrnn79xxeh1u52x65.png&quot; alt=&quot;Trace details&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;how-ai-helped-me-ship-this-in-under-a-month&quot;&gt;How AI helped me ship this in under a month&lt;/h3&gt;
&lt;p&gt;I want to be upfront about this. I used Claude Code a lot, especially for the frontend. I'm a backend dev. I know .NET, EF Core, gRPC. But Vue and Nuxt? Not really my thing. Without Claude Code I would have spent days stuck on CSS and component wiring. Instead I could focus on what I actually care about: the OTLP ingestion, the storage layer, how widgets work.&lt;/p&gt;
&lt;p&gt;All the architecture and decisions are mine. The AI did the typing.&lt;/p&gt;
&lt;p&gt;And honestly, I think that's the best use of these tools right now if you're a solo dev. They don't replace you. They cover the parts where you're slow, so you can ship something that would normally need a small team.&lt;/p&gt;
&lt;h3 id=&quot;some-stuff-i-like-about-how-it-turned-out&quot;&gt;Some stuff I like about how it turned out&lt;/h3&gt;
&lt;p&gt;You can install widget packs from Git repos. There's a &lt;code&gt;pack.json&lt;/code&gt; that bundles widgets and dashboards together, so if you have a team you can share a curated setup.&lt;/p&gt;
&lt;p&gt;There's also a &lt;code&gt;spec&lt;/code&gt; engine for building custom widgets with Mustache templates. It's sandboxed with DOMPurify, no JS eval. I wanted people to be able to build their own cards without opening security holes.&lt;/p&gt;
&lt;p&gt;Retention is configurable per signal. Set 7 days for traces, 30 for metrics, whatever you want. Old data gets cleaned up on its own.&lt;/p&gt;
&lt;p&gt;And there's an MCP server at &lt;code&gt;/mcp&lt;/code&gt; if you use Claude Code or similar tools. You can query your telemetry from the LLM directly. I didn't expect to use this much but it's actually pretty handy when debugging.&lt;/p&gt;
&lt;h3 id=&quot;try-it&quot;&gt;Try it&lt;/h3&gt;
&lt;p&gt;Quickest way:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker compose up --build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This starts everything including a sample workload that generates real data, so you're not staring at an empty dashboard.&lt;/p&gt;
&lt;p&gt;Or just check the live demo. It's a static build with mock data, login accepts any password: &lt;a rel=&quot;nofollow&quot; href=&quot;https://adiakys.github.io/OtlpDashboard/&quot;&gt;Live demo&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The repo is here: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Adiakys/OtlpDashboard&quot;&gt;GitHub&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;It's GPLv3. If you try it out let me know what you think, I'm curious what's missing or broken.&lt;/p&gt;
</description>
<category>Launches</category>
<guid isPermaLink="true">https://coderlegion.com/20644/opentelemetry-native-all-in-one-simple</guid>
<pubDate>Mon, 15 Jun 2026 23:34:33 +0000</pubDate>
</item>
<item>
<title>Heimdall MCP</title>
<link>https://coderlegion.com/17437/heimdall-mcp</link>
<description>&lt;p&gt;If you've been building with MCP servers lately, you've probably hit this wall: something goes wrong (or just feels slow) and you have zero visibility into what actually happened.&lt;/p&gt;
&lt;p&gt;Which tool did Claude call? What input did it send? Did the server error silently? How long did it take?&lt;/p&gt;
&lt;p&gt;That's the gap Heimdall fills.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-heimdall-&quot;&gt;What is Heimdall?&lt;/h3&gt;
&lt;p&gt;Heimdall is a transparent proxy for MCP servers. It sits between your MCP client (Claude Desktop, OpenCode, Cursor, or any other) and your MCP server — local or remote — and records every interaction as an OpenTelemetry span.&lt;/p&gt;
&lt;p&gt;No modifications to your server. No SDK to integrate. No infra to run. Just wrap your existing server and start seeing what's happening inside.&lt;/p&gt;
&lt;p&gt;The name comes from Norse mythology: Heimdall is the guardian of the Bifrost bridge, with the ability to see and hear everything that crosses between worlds. That's exactly the role this proxy plays.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-problem-in-one-screenshot&quot;&gt;The problem in one screenshot&lt;/h3&gt;
&lt;p&gt;You're running a Claude agent that orchestrates 5+ MCP tools. One of them is consistently slow. Another occasionally returns empty results. You have no way to know which one, when, or why — unless you dig into logs manually.&lt;/p&gt;
&lt;p&gt;With Heimdall, every tool call becomes a structured span:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;name&quot;: &quot;mcp.tool.call&quot;,
  &quot;attributes&quot;: {
    &quot;gen_ai.tool.name&quot;: &quot;search_documents&quot;,
    &quot;mcp.duration_ms&quot;: 843,
    &quot;mcp.status&quot;: &quot;ok&quot;
  },
  &quot;events&quot;: [
    { &quot;name&quot;: &quot;request&quot;,  &quot;body&quot;: { &quot;query&quot;: &quot;quarterly report&quot; } },
    { &quot;name&quot;: &quot;response&quot;, &quot;body&quot;: { &quot;results&quot;: [...] } }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Stored. Queryable. Yours.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;zero-config-wrapping-via-mcp-json&quot;&gt;Zero config wrapping via mcp.json&lt;/h3&gt;
&lt;p&gt;The easiest way to use Heimdall requires zero access to the server's source code. Just install it globally and update your &lt;code&gt;mcp.json&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install -g @cardor/heimdall-mcp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt; — your current config:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;mcpServers&quot;: {
    &quot;my-server&quot;: {
      &quot;command&quot;: &quot;node&quot;,
      &quot;args&quot;: [&quot;my-server.js&quot;]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt; — wrapped with Heimdall:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;mcpServers&quot;: {
    &quot;my-server&quot;: {
      &quot;command&quot;: &quot;heimdall&quot;,
      &quot;args&quot;: [
        &quot;--store&quot;, &quot;sqlite://~/.heimdall/traces.db&quot;,
        &quot;--&quot;,
        &quot;node&quot;, &quot;my-server.js&quot;
      ]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it. Restart your client and every tool call starts being recorded. The &lt;code&gt;--&lt;/code&gt; separator tells Heimdall where its args end and the real server command begins.&lt;/p&gt;
&lt;p&gt;For remote servers (HTTP or SSE), it's just as simple:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;mcpServers&quot;: {
    &quot;remote-server&quot;: {
      &quot;command&quot;: &quot;heimdall&quot;,
      &quot;args&quot;: [
        &quot;--store&quot;, &quot;postgres://user:pass@localhost/mydb&quot;,
        &quot;--target&quot;, &quot;http://my-remote-mcp.com/sse&quot;
      ]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Heimdall exposes stdio to your client, talks HTTP/SSE to the real server, and intercepts everything in between.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-gets-recorded&quot;&gt;What gets recorded&lt;/h3&gt;
&lt;p&gt;Heimdall captures all standard MCP events:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Event &lt;/th&gt;&lt;th&gt;What's recorded&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;tools/call&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Tool name, input args, response, duration, status&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;tools/list&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Available tools and their schemas&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;resources/read&lt;/code&gt; &lt;/td&gt;&lt;td&gt;URI, MIME type, response size&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;prompts/get&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Prompt name, arguments, rendered output&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;initialize&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Client/server versions, negotiated capabilities&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;shutdown&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Total session duration&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Every span follows the &lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/&quot;&gt;OpenTelemetry gen_ai.* semantic conventions&lt;/a&gt;, so if you later want to send traces to Jaeger, Honeycomb, or Grafana Tempo, the data is already well-formed.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;storage-options&quot;&gt;Storage options&lt;/h3&gt;
&lt;p&gt;Pick the backend that fits your setup:&lt;/p&gt;
&lt;h4 id=&quot;sqlite-local-file-zero-infra&quot;&gt;SQLite — local file, zero infra&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;--store sqlite://./traces.db
# or with absolute path
--store sqlite:///Users/you/.heimdall/traces.db
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Best for: local development, single-machine setups, quick debugging sessions.&lt;/p&gt;
&lt;p&gt;Uses &lt;code&gt;@libsql/client&lt;/code&gt; under the hood — pure WASM, no native bindings, no node-gyp.&lt;/p&gt;
&lt;h4 id=&quot;postgres-your-existing-database&quot;&gt;Postgres — your existing database&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;--store postgres://user:password@localhost:5432/mydb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Best for: production environments, teams sharing observability data, long-term storage with SQL queries across multiple servers.&lt;/p&gt;
&lt;h4 id=&quot;mysql&quot;&gt;MySQL&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;--store mysql://user:password@localhost:3306/mydb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same use case as Postgres. Pick whichever you already run.&lt;/p&gt;
&lt;p&gt;All three use &lt;a rel=&quot;nofollow&quot; href=&quot;https://orm.drizzle.team/&quot;&gt;drizzle-orm&lt;/a&gt; with a shared schema, so the query experience is consistent regardless of which backend you choose.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;using-it-as-a-typescript-library&quot;&gt;Using it as a TypeScript library&lt;/h3&gt;
&lt;p&gt;If you have access to your server's code and want tighter integration, Heimdall ships as a fully-typed TypeScript library with a fluent builder API:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { McpProxy } from 'heimdall-mcp'

const proxy = await McpProxy
  .create()
  .inbound({ transport: 'stdio' })
  .outbound({ transport: 'http', url: 'http://localhost:3001' })
  .store('sqlite://./traces.db')
  .build()

await proxy.start()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can also plug in custom interceptors — for example, to redact sensitive fields before they're persisted, or to add your own business logic on top of the tracing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { McpProxy, type Interceptor } from 'heimdall-mcp'

// Custom interceptor: redact API keys from tool inputs before storing
const redactSecrets: Interceptor = {
  name: 'redact-secrets',
  async intercept(request, ctx, next) {
    const sanitized = redactSensitiveFields(request)
    return next(sanitized)
  }
}

const proxy = await McpProxy
  .create()
  .inbound({ transport: 'stdio' })
  .outbound({ transport: 'http', url: 'http://localhost:3001' })
  .store('postgres://user:pass@host/db')
  .intercept(redactSecrets)
  .build()
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;design-goals&quot;&gt;Design goals&lt;/h3&gt;
&lt;p&gt;A few decisions worth calling out:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No native dependencies.&lt;/strong&gt; SQLite runs via WASM (&lt;code&gt;@libsql/client&lt;/code&gt;), Postgres via the pure-JS &lt;code&gt;postgres&lt;/code&gt; package, MySQL via &lt;code&gt;mysql2&lt;/code&gt;. You can install and run Heimdall in any Node 22+ environment without compilation steps.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Transport mixing.&lt;/strong&gt; Your client connects via stdio. Your server can be stdio, HTTP, or SSE. Heimdall bridges them transparently — useful for wrapping local CLI servers behind an HTTP interface without touching their code.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;get-started&quot;&gt;Get started&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install -g @cardor/heimdall-mcp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/enmanuelmag/heimdall-mcp&quot;&gt;github.com/enmanuelmag/heimdall-mcp&lt;/a&gt;&lt;br&gt;
Website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://stack.cardor.dev/heimdall&quot;&gt;https://stack.cardor.dev/heimdall&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;If you try it out, I'd love to hear what you think — especially feedback on the interceptor API design and the OTel schema. Open an issue or find me on X \[@enmanuelmag\].&lt;/p&gt;
</description>
<category>Launches</category>
<guid isPermaLink="true">https://coderlegion.com/17437/heimdall-mcp</guid>
<pubDate>Mon, 11 May 2026 17:41:11 +0000</pubDate>
</item>
<item>
<title>OpenTelemetry just standardized LLM tracing. Here's what it actually looks like in code.</title>
<link>https://coderlegion.com/13760/opentelemetry-just-standardized-llm-tracing-heres-what-it-actually-looks-like-in-code</link>
<description>&lt;p&gt;Every LLM tool invents its own tracing format. Langfuse has one. Helicone has one. Arize has one. If you built your own — congratulations, you have one too.&lt;/p&gt;
&lt;p&gt;OpenTelemetry just published a standard for all of them.&lt;/p&gt;
&lt;p&gt;It defines how to name spans, what attributes a tool call should have, how to log prompts without leaking PII, and which span kind to use for an agent. It's called GenAI Semantic Conventions. It's experimental. And almost nobody has written about what it actually looks like when you implement it.&lt;/p&gt;
&lt;p&gt;I know because I searched. &quot;OTel GenAI semantic conventions&quot; gives you spec pages. Zero practical articles. &quot;How to trace LLM agent with OpenTelemetry&quot; gives you StackOverflow questions with no answers.&lt;/p&gt;
&lt;p&gt;We implemented it. Four PRs, a gap analysis, real before/after code. We also discovered, mid-implementation, that our traces never exported at all — but that's a &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/vola-trebla/i-audited-my-tool-fixed-44-bugs-and-it-still-didnt-work-4omk&quot;&gt;different story&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Here's what the spec actually says, where we got it wrong, and what you should do today.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-wild-west-of-llm-tracing&quot;&gt;The wild west of LLM tracing&lt;/h3&gt;
&lt;p&gt;Right now, if you trace LLM calls, you're probably doing something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;span.setAttribute(&quot;llm.provider&quot;, &quot;openai&quot;);
span.setAttribute(&quot;llm.model&quot;, &quot;gpt-4o&quot;);
span.setAttribute(&quot;llm.tokens.input&quot;, 150);
span.setAttribute(&quot;llm.cost&quot;, 0.003);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's what we did in toad-eye v1. Made sense to us. Worked fine in our dashboards.&lt;/p&gt;
&lt;p&gt;Problem: nobody else's dashboards understand these attributes. Switch from Jaeger to Arize Phoenix — reconfigure everything. Export traces to Datadog — they see raw spans with no LLM context. Your tracing is a walled garden. You built vendor lock-in into your own code.&lt;/p&gt;
&lt;p&gt;This is exactly what OpenTelemetry was created to solve. And now it has a spec for GenAI.&lt;/p&gt;
&lt;h3 id=&quot;three-types-of-genai-spans&quot;&gt;Three types of GenAI spans&lt;/h3&gt;
&lt;p&gt;The spec defines three operations. Every LLM-related span gets one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;chat gpt-4o                    ← model call
invoke_agent orchestrator      ← agent invocation  
execute_tool web_search        ← tool execution
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The span name format is &lt;code&gt;{operation} {name}&lt;/code&gt;. Not your custom format. Not &lt;code&gt;gen_ai.openai.gpt-4o&lt;/code&gt; (that's what we had — no backend recognizes it).&lt;/p&gt;
&lt;p&gt;Here's what we changed:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8ezxomm21gsx5yjv1m5f.png&quot; alt=&quot;Screenshot: Span&quot;&gt;&lt;br&gt;
&lt;em&gt;Span naming migration: the old format was invisible to every GenAI-aware backend.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;agent-attributes-we-had-the-paths-wrong&quot;&gt;Agent attributes — we had the paths wrong&lt;/h3&gt;
&lt;p&gt;If you're building agents (ReAct, tool-use, multi-step), the spec defines identity and tool attributes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// What OTel says:
span.setAttribute(&quot;gen_ai.agent.name&quot;, &quot;weather-bot&quot;);
span.setAttribute(&quot;gen_ai.agent.id&quot;, &quot;agent-001&quot;);
span.setAttribute(&quot;gen_ai.tool.name&quot;, &quot;search&quot;);
span.setAttribute(&quot;gen_ai.tool.type&quot;, &quot;function&quot;);

// What we had:
span.setAttribute(&quot;gen_ai.agent.tool.name&quot;, &quot;search&quot;);  // wrong path
// gen_ai.agent.name — didn't exist at all
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;gen_ai.agent.tool.name&lt;/code&gt; path looks reasonable. It even reads well. But the spec puts tool attributes at &lt;code&gt;gen_ai.tool.*&lt;/code&gt; — flat, not nested under agent. Our format, again, invisible to any backend that follows the standard.&lt;/p&gt;
&lt;h3 id=&quot;content-recording-the-spec-agrees-with-us-feels-good-&quot;&gt;Content recording — the spec agrees with us (feels good)&lt;/h3&gt;
&lt;p&gt;This was the one thing we got right from day one, and it's worth calling out because most teams get it wrong.&lt;/p&gt;
&lt;p&gt;The spec says: &lt;strong&gt;don't record prompts and completions by default.&lt;/strong&gt; Instrumentations SHOULD NOT capture content unless explicitly enabled.&lt;/p&gt;
&lt;p&gt;Three official patterns:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Default: don't record.&lt;/strong&gt; No prompt, no completion in spans. Privacy first.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Opt-in via span attributes.&lt;/strong&gt; &lt;code&gt;gen_ai.input.messages&lt;/code&gt; and &lt;code&gt;gen_ai.output.messages&lt;/code&gt; as JSON strings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;External storage.&lt;/strong&gt; Store content elsewhere, put a reference on the span.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;We had &lt;code&gt;recordContent: false&lt;/code&gt; as default since v1. When the spec confirmed this approach, it was one of those rare moments where your gut feeling gets validated by a committee of very smart people.&lt;/p&gt;
&lt;p&gt;If you're logging prompts in spans by default — you might want to reconsider before your security team does it for you.&lt;/p&gt;
&lt;h3 id=&quot;the-honest-gap-analysis&quot;&gt;The honest gap analysis&lt;/h3&gt;
&lt;p&gt;Here's the full picture. No spin, no cherry-picking.&lt;/p&gt;
&lt;h4 id=&quot;what-we-got-right-from-day-1&quot;&gt;What we got right from day 1&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Our attribute &lt;/th&gt;&lt;th&gt;OTel spec &lt;/th&gt;&lt;th&gt;Verdict&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;gen_ai.provider.name&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;gen_ai.provider.name&lt;/code&gt; &lt;/td&gt;&lt;td&gt;✅ Exact match&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;gen_ai.request.model&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;gen_ai.request.model&lt;/code&gt; &lt;/td&gt;&lt;td&gt;✅ Exact match&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;gen_ai.usage.input_tokens&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;gen_ai.usage.input_tokens&lt;/code&gt; &lt;/td&gt;&lt;td&gt;✅ Exact match&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;error.type&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;error.type&lt;/code&gt; &lt;/td&gt;&lt;td&gt;✅ Exact match&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;what-we-got-wrong&quot;&gt;What we got wrong&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What &lt;/th&gt;&lt;th&gt;Our version &lt;/th&gt;&lt;th&gt;OTel spec &lt;/th&gt;&lt;th&gt;Status&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Span name &lt;/td&gt;&lt;td&gt;&lt;code&gt;gen_ai.openai.gpt-4o&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;chat gpt-4o&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Fixed&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tool name attribute &lt;/td&gt;&lt;td&gt;&lt;code&gt;gen_ai.agent.tool.name&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;gen_ai.tool.name&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Fixed&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Custom attributes &lt;/td&gt;&lt;td&gt;&lt;code&gt;gen_ai.agent.step.*&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Reserved namespace &lt;/td&gt;&lt;td&gt;Moved to &lt;code&gt;gen_ai.toad_eye.*&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Agent identity &lt;/td&gt;&lt;td&gt;Didn't exist &lt;/td&gt;&lt;td&gt;&lt;code&gt;gen_ai.agent.name&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Added&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;what-we-built-beyond-the-spec&quot;&gt;What we built beyond the spec&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Feature &lt;/th&gt;&lt;th&gt;Namespace &lt;/th&gt;&lt;th&gt;Why it's not in OTel&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Cost per request &lt;/td&gt;&lt;td&gt;&lt;code&gt;gen_ai.toad_eye.cost&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Pricing is vendor-specific&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Budget guards &lt;/td&gt;&lt;td&gt;&lt;code&gt;gen_ai.toad_eye.budget.*&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Runtime enforcement ≠ observability&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Shadow guardrails &lt;/td&gt;&lt;td&gt;&lt;code&gt;gen_ai.toad_eye.guard.*&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Validation is app-level&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Semantic drift &lt;/td&gt;&lt;td&gt;&lt;code&gt;gen_ai.toad_eye.semantic_drift&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Quality metric, not trace standard&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;ReAct step tracking &lt;/td&gt;&lt;td&gt;&lt;code&gt;gen_ai.toad_eye.agent.step.*&lt;/code&gt; &lt;/td&gt;&lt;td&gt;ReAct is one pattern; spec is pattern-agnostic&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The key insight: &lt;strong&gt;OTel spec covers WHAT happened. We cover WHY and HOW MUCH.&lt;/strong&gt; Not competing — complementary. Your custom metrics go under your namespace. The spec's attributes go where backends expect them.&lt;/p&gt;
&lt;h3 id=&quot;the-migration-dual-emit-don-t-break-users&quot;&gt;The migration: dual-emit, don't break users&lt;/h3&gt;
&lt;p&gt;We didn't do a clean break. v2.4 emits both old and new attribute names:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// New (OTel spec-compliant)
span.setAttribute(&quot;gen_ai.tool.name&quot;, toolName);

// Old (deprecated, still emitted for backward compat)
span.setAttribute(&quot;gen_ai.agent.tool.name&quot;, toolName);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l4qj269knbpmegcwwtlt.png&quot; alt=&quot;Screenshot: Attribute prefix migration diff with @deprecated tags and dual-emit&quot;&gt;&lt;br&gt;
&lt;em&gt;Dual-emit approach: old attributes get &lt;code&gt;@deprecated&lt;/code&gt;, new ones follow the spec. Both emitted until v3.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;An environment variable controls when to stop emitting deprecated attributes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This was four PRs (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/vola-trebla/toad-eye/pull/170&quot;&gt;#170&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/vola-trebla/toad-eye/pull/171&quot;&gt;#171&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/vola-trebla/toad-eye/pull/172&quot;&gt;#172&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/vola-trebla/toad-eye/pull/173&quot;&gt;#173&lt;/a&gt;). v3 will remove the deprecated aliases entirely.&lt;/p&gt;
&lt;h3 id=&quot;the-irony&quot;&gt;The irony&lt;/h3&gt;
&lt;p&gt;While implementing all of this, we did a round of manual testing.&lt;/p&gt;
&lt;p&gt;Turns out our traces never exported. At all. Ever. The OTel &lt;code&gt;NodeSDK&lt;/code&gt; silently disables trace export when you pass &lt;code&gt;spanProcessors: []&lt;/code&gt;. We had 252 passing tests. All of them mocked the SDK.&lt;/p&gt;
&lt;p&gt;So we standardized our attributes perfectly — for traces that nobody could see.&lt;/p&gt;
&lt;p&gt;We fixed both. Published six patch versions in one day. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/vola-trebla/i-audited-my-tool-fixed-44-bugs-and-it-still-didnt-work-4omk&quot;&gt;full story is in article #2&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;which-backends-actually-support-this&quot;&gt;Which backends actually support this&lt;/h3&gt;
&lt;p&gt;This is the reason to care. Emit the right attributes today → six backends visualize your traces tomorrow:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Backend &lt;/th&gt;&lt;th&gt;Recognizes GenAI spans &lt;/th&gt;&lt;th&gt;Agent visualization &lt;/th&gt;&lt;th&gt;Cost&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Jaeger &lt;/td&gt;&lt;td&gt;Basic (nested spans) &lt;/td&gt;&lt;td&gt;Hierarchy view &lt;/td&gt;&lt;td&gt;Free&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Arize Phoenix &lt;/td&gt;&lt;td&gt;Full GenAI UI &lt;/td&gt;&lt;td&gt;Agent workflow &lt;/td&gt;&lt;td&gt;Free tier&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;SigNoz &lt;/td&gt;&lt;td&gt;GenAI dashboards &lt;/td&gt;&lt;td&gt;Nested spans &lt;/td&gt;&lt;td&gt;Free / Cloud&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Datadog &lt;/td&gt;&lt;td&gt;LLM Observability &lt;/td&gt;&lt;td&gt;Agent tracing &lt;/td&gt;&lt;td&gt;Paid&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Langfuse &lt;/td&gt;&lt;td&gt;Full GenAI UI &lt;/td&gt;&lt;td&gt;Session view &lt;/td&gt;&lt;td&gt;Free tier&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Grafana + Tempo &lt;/td&gt;&lt;td&gt;Query by attributes &lt;/td&gt;&lt;td&gt;Custom dashboards &lt;/td&gt;&lt;td&gt;Free&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;No vendor lock-in. One set of attributes. Six places to visualize them.&lt;/p&gt;
&lt;h3 id=&quot;what-you-should-do-today&quot;&gt;What you should do today&lt;/h3&gt;
&lt;p&gt;If you're tracing LLM calls — even with custom code — aligning with the spec now saves you pain later. The conventions are experimental, but the direction is locked in.&lt;/p&gt;
&lt;p&gt;Quick checklist:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Set &lt;code&gt;gen_ai.operation.name&lt;/code&gt; on every LLM span: &lt;code&gt;chat&lt;/code&gt;, &lt;code&gt;invoke_agent&lt;/code&gt;, or &lt;code&gt;execute_tool&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Format span names as &lt;code&gt;{operation} {model_or_agent_name}&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Use official attributes: &lt;code&gt;gen_ai.agent.name&lt;/code&gt;, &lt;code&gt;gen_ai.tool.name&lt;/code&gt;, &lt;code&gt;gen_ai.tool.type&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Put YOUR custom attributes under YOUR namespace — not &lt;code&gt;gen_ai.*&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Don't record prompt/completion by default — make it opt-in&lt;/li&gt;
&lt;li&gt;Test your traces in at least 2 backends (Jaeger + one GenAI-specific like Phoenix)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Full spec: &lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/specs/semconv/gen-ai/&quot;&gt;OpenTelemetry GenAI Semantic Conventions&lt;/a&gt;&lt;br&gt;
Agent spans: &lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/&quot;&gt;GenAI Agent Spans&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Previous articles:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/vola-trebla/my-ai-bot-burned-through-my-api-budget-overnight-so-i-built-an-open-source-tool-to-make-sure-it-2372&quot;&gt;#1: My AI bot burned through my API budget overnight&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/vola-trebla/i-audited-my-tool-fixed-44-bugs-and-it-still-didnt-work-4omk&quot;&gt;#2: I audited my tool, fixed 44 bugs — and it still didn't work&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;toad-eye&lt;/strong&gt; — open-source LLM observability, OTel-native: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/vola-trebla/toad-eye&quot;&gt;GitHub&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/toad-eye&quot;&gt;npm&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;️&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13760/opentelemetry-just-standardized-llm-tracing-heres-what-it-actually-looks-like-in-code</guid>
<pubDate>Tue, 31 Mar 2026 10:24:21 +0000</pubDate>
</item>
<item>
<title>Distributed Tracing in Spring Boot: A Practical Guide to OpenTelemetry and Jaeger</title>
<link>https://coderlegion.com/10841/distributed-tracing-in-spring-boot-a-practical-guide-to-opentelemetry-and-jaeger</link>
<description>&lt;h3 id=&quot;tl-dr&quot;&gt;TL;DR&lt;/h3&gt;
&lt;p&gt;Distributed tracing helps you understand how requests flow through microservices by tracking every hop with minimal overhead. This guide covers OpenTelemetry integration in Spring Boot 4 using the native starter, explains core concepts like spans and context propagation, and demonstrates Jaeger-based tracing with best practices for production. Whether you're debugging latency issues or optimizing service dependencies, distributed tracing provides the visibility modern architectures demand.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/rajkundalia/learning-distributed-tracing&quot;&gt;learning-distributed-tracing&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;section&quot;&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/giyd98se0uq495t6vvvi.png&quot; alt=&quot;Image1&quot;&gt;&lt;/h3&gt;
&lt;h3 id=&quot;the-problem-debugging-in-the-dark&quot;&gt;The Problem: Debugging in the Dark&lt;/h3&gt;
&lt;p&gt;In a monolithic application, debugging a slow request is straightforward. Add some logging, attach a profiler, and you can see exactly where time is spent. But microservices change everything. A single user request might touch ten or more services, each with its own logs. Failures often happen between services, not inside them. When something breaks or slows down, where do you even start?&lt;/p&gt;
&lt;p&gt;Traditional logging falls short here. Sure, you can correlate logs by request ID, but manually piecing together the journey across services, databases, and queues is tedious and error-prone. You need something that automatically tracks the entire execution path, measures timing at each step, and shows you the complete picture. That's distributed tracing.&lt;/p&gt;
&lt;h3 id=&quot;understanding-observability-metrics-logs-and-traces&quot;&gt;Understanding Observability: Metrics, Logs, and Traces&lt;/h3&gt;
&lt;p&gt;Modern observability rests on three pillars. &lt;strong&gt;Metrics&lt;/strong&gt; are numerical measurements like CPU usage or request count—great for alerting but lacking context for debugging. &lt;strong&gt;Logs&lt;/strong&gt; are discrete events that tell you what happened at a specific moment but struggle with correlation across distributed systems. &lt;strong&gt;Traces&lt;/strong&gt; capture the complete journey of a request through your system, showing execution flow and timing.&lt;/p&gt;
&lt;p&gt;These pillars complement each other. Metrics tell you there's a problem, logs provide event details, and traces show you the execution path. Together, they form a complete observability strategy.&lt;/p&gt;
&lt;p&gt;It's worth distinguishing observability from monitoring. &lt;strong&gt;Monitoring&lt;/strong&gt; answers &quot;Is the system healthy?&quot; through dashboards and alerts. &lt;strong&gt;Observability&lt;/strong&gt; answers &quot;Why is the system behaving this way?&quot; by designing systems to answer questions you didn't anticipate. Distributed tracing is a core enabler of observability, not a replacement for monitoring.&lt;/p&gt;
&lt;h3 id=&quot;the-fundamentals-of-distributed-tracing&quot;&gt;The Fundamentals of Distributed Tracing&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Telemetry&lt;/strong&gt; refers to automated data collection from remote sources—your application constantly reporting its health and activity. &lt;strong&gt;Spans&lt;/strong&gt; are the building blocks of traces, representing units of work with start time, duration, and metadata. When Service A calls Service B, both create spans that form a parent-child relationship showing the call hierarchy.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Traces&lt;/strong&gt; are collections of spans representing a single transaction. A trace ID ties all related spans together across service boundaries. &lt;strong&gt;Context Propagation&lt;/strong&gt; maintains trace continuity—when Service A calls Service B, it passes the trace context in HTTP headers, allowing Service B to create child spans under the same trace.&lt;/p&gt;
&lt;h3 id=&quot;opentelemetry-the-industry-standard&quot;&gt;OpenTelemetry: The Industry Standard&lt;/h3&gt;
&lt;p&gt;Before OpenTelemetry, every observability vendor had proprietary SDKs and formats. If you wanted to switch from Jaeger to Zipkin, you'd re-instrument your entire codebase. This vendor lock-in meant architectural decisions became permanent commitments.&lt;/p&gt;
&lt;p&gt;OpenTelemetry is a vendor-neutral framework providing APIs, SDKs, and tools for telemetry data. Formed by merging OpenTracing and OpenCensus, it provides a single instrumentation API that works with any backend. The value proposition is simple: instrument once, send data anywhere.&lt;/p&gt;
&lt;p&gt;The architecture includes the &lt;strong&gt;API and SDK&lt;/strong&gt; for creating telemetry, &lt;strong&gt;Auto-instrumentation&lt;/strong&gt; for frameworks like Spring and JDBC, and the &lt;strong&gt;Collector&lt;/strong&gt;—an optional but recommended component that receives, processes, and exports telemetry.&lt;/p&gt;
&lt;p&gt;While this article focuses on distributed tracing, it's worth noting that OpenTelemetry standardizes all three pillars of observability—metrics, logs, and traces. The same SDK and protocol handle all three, giving you a unified approach to instrumentation across your entire observability stack.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OTLP (OpenTelemetry Protocol)&lt;/strong&gt; is the wire format for transmitting telemetry data. Supporting both gRPC and HTTP transports, OTLP defines how traces, metrics, and logs are serialized and sent to collectors or backends. The protocol handles backpressure, retries, and batching for reliable delivery. Most modern observability tools now support OTLP natively, making it the de facto standard.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lue2hi3fad8jsa6m9kfa.png&quot; alt=&quot;Image2&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;spring-boot-4-and-opentelemetry-integration&quot;&gt;Spring Boot 4 and OpenTelemetry Integration&lt;/h3&gt;
&lt;p&gt;Spring Boot 4 brings first-class support for OpenTelemetry through the &lt;code&gt;spring-boot-starter-opentelemetry&lt;/code&gt; dependency. This starter provides automatic configuration and instrumentation for common scenarios like HTTP requests, database calls, and messaging.&lt;/p&gt;
&lt;p&gt;Previous versions of Spring Boot required manual setup using the OpenTelemetry Java agent or custom configuration. Spring Boot 2 and 3 users could leverage the Java agent for bytecode instrumentation, which worked but added operational complexity. The agent approach meant deploying a JAR alongside your application and configuring it via environment variables or system properties.&lt;/p&gt;
&lt;p&gt;With Spring Boot 4, the starter eliminates much of this complexity. Add the dependency, configure a few properties, and you're done. Under the hood, it uses Spring's auto-configuration to set up the OpenTelemetry SDK, register instrumentation libraries, and configure exporters based on your application properties.&lt;/p&gt;
&lt;p&gt;The starter automatically instruments:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;HTTP requests and responses via Spring MVC and WebFlux &lt;/li&gt;
&lt;li&gt;RestTemplate, RestClient, and WebClient calls &lt;/li&gt;
&lt;li&gt;JDBC database operations &lt;/li&gt;
&lt;li&gt;Logs (automatically includes trace and span IDs)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For additional instrumentation like Kafka messaging, you can use the &lt;code&gt;@WithSpan&lt;/code&gt; annotation for manual instrumentation, or use the OpenTelemetry Java Agent which provides automatic instrumentation for 150+ libraries.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Spring Boot Actuator's Role&lt;/strong&gt;: While Actuator isn't required for tracing, it plays a complementary role in Spring Boot 4's observability story. Actuator's &lt;code&gt;ObservationRegistry&lt;/code&gt; is what actually observes requests and framework operations. The OpenTelemetry starter bridges these observations into OTel-compliant traces. Think of Actuator as operational introspection (health, metrics) and OpenTelemetry as behavioral introspection (request flows).&lt;/p&gt;
&lt;p&gt;You can still use the Java agent if you need instrumentation for libraries outside Spring's ecosystem, but for typical Spring Boot applications, the starter is sufficient and more maintainable. Framework-level instrumentation gives you baseline visibility automatically, while custom spans should be added only where domain insight is needed. This balance is critical—over-instrumentation creates noise, while under-instrumentation hides intent.&lt;/p&gt;
&lt;h3 id=&quot;jaeger-your-trace-backend&quot;&gt;Jaeger: Your Trace Backend&lt;/h3&gt;
&lt;p&gt;Jaeger is an open-source distributed tracing platform originally developed by Uber, providing storage, querying, and visualization for traces. While OpenTelemetry handles generation and collection, Jaeger handles the backend.&lt;/p&gt;
&lt;p&gt;Jaeger's architecture includes agents, collectors, a query service, and a web UI. For development, the all-in-one Docker image combines all components. A common misconception is that Jaeger requires Kubernetes—it doesn't. Jaeger runs on Docker, VMs, or bare metal. The all-in-one image works for local development, while production typically uses separate components with external storage like Cassandra or Elasticsearch.&lt;/p&gt;
&lt;p&gt;Jaeger supports multiple ingestion formats, including OTLP. With OpenTelemetry's standardization, OTLP is now recommended, meaning your Spring Boot application sends traces in OTLP format directly to Jaeger without needing Jaeger-specific libraries.&lt;/p&gt;
&lt;h3 id=&quot;tracing-beyond-services-databases-and-message-queues&quot;&gt;Tracing Beyond Services: Databases and Message Queues&lt;/h3&gt;
&lt;p&gt;One of the most powerful aspects of distributed tracing is visibility into external dependencies. When your application makes a database call or publishes to Kafka, those operations appear as spans in your trace.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Database tracing&lt;/strong&gt; works through JDBC instrumentation. When your Spring Boot application executes a SQL query, the OpenTelemetry instrumentation automatically creates a span containing the query, execution time, and database connection details. This visibility is crucial for identifying slow queries or N+1 problems—those situations where you're executing one query to fetch entities, then N additional queries to fetch related data for each entity. Database spans make these anti-patterns immediately visible in your trace timeline. However, be mindful of sensitive data. Database spans can include SQL statements with parameter values, which might contain PII. OpenTelemetry provides span processors to redact or mask sensitive information before export.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Message queue tracing&lt;/strong&gt; extends traces across asynchronous boundaries. When Service A publishes a message to Kafka, it injects the trace context into message headers. When Service B consumes that message, it extracts the context and continues the trace. This creates a parent-child relationship between the producer and consumer spans, even though they execute at different times. The result is end-to-end visibility into asynchronous workflows, making it much easier to debug message processing issues or track down where data transformations went wrong.&lt;/p&gt;
&lt;h3 id=&quot;performance-impact-and-production-considerations&quot;&gt;Performance Impact and Production Considerations&lt;/h3&gt;
&lt;p&gt;Distributed tracing adds overhead from creating spans, serializing data, and network transmission. The impact varies by component:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CPU&lt;/strong&gt;: Span creation and serialization typically add microseconds per operation. The OpenTelemetry SDK uses efficient batching to minimize per-span overhead.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Memory&lt;/strong&gt;: The SDK buffers spans before export. Configure batch size and timeout based on traffic patterns and memory constraints to prevent excessive buffering.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Network IO&lt;/strong&gt;: Sending traces to a local collector over localhost has minimal impact. Remote backends introduce latency and bandwidth usage. Using a collector to batch and compress traces reduces network overhead significantly. Importantly, the collector absorbs most of the performance cost, acting as a buffer between your applications and backends.&lt;/p&gt;
&lt;p&gt;In practice, overhead is typically under 5 percent for CPU and memory. The key is intelligent sampling—trace 1-5 percent of traffic in production rather than every request (development should trace 100 percent for debugging). OpenTelemetry supports probability-based sampling for production and rate-limiting to cap traces per second.&lt;/p&gt;
&lt;h3 id=&quot;best-practices-for-distributed-tracing&quot;&gt;Best Practices for Distributed Tracing&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Use meaningful span names&lt;/strong&gt;: &quot;validatePaymentRequest&quot; beats &quot;process&quot; every time. Good naming makes traces self-documenting.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Add relevant attributes&lt;/strong&gt;: Follow OpenTelemetry semantic conventions for HTTP, databases, and queues. Add custom attributes for business context like user ID or tenant ID.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don't over-instrument&lt;/strong&gt;: Creating spans for every method produces noise. Focus on external calls, database queries, and significant business logic.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Implement proper error handling&lt;/strong&gt;: Mark spans as failed and record exception details when errors occur. This helps identify which service and operation caused failures.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sample intelligently&lt;/strong&gt;: Trace everything in development (probability 1.0), but use 1-5 percent sampling in production (probability 0.01-0.05). This gives you statistically significant insights without overloading infrastructure. Consider adaptive sampling that increases rates for slow requests or errors.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Watch for orphaned spans&lt;/strong&gt;: When requests hand off work to async thread pools, ensure context propagation is maintained. If a new thread loses the trace context, your trace will break, resulting in disconnected &quot;orphaned spans&quot; that can't be correlated. Spring Boot 4 usually handles this automatically, but verify your custom executors are properly instrumented.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use the Collector&lt;/strong&gt;: It provides buffering, enrichment, routing, and reliability that SDK exporters alone cannot.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Monitor your telemetry pipeline&lt;/strong&gt;: Track export success rates and latency. If your pipeline breaks, you're debugging blind.&lt;/p&gt;
&lt;h3 id=&quot;querying-and-analyzing-traces&quot;&gt;Querying and Analyzing Traces&lt;/h3&gt;
&lt;p&gt;Jaeger's UI provides powerful analysis tools. Search for traces by service, operation, tags, duration, and time range. The trace timeline shows the complete request flow with parent-child relationships visually nested. For advanced use cases, Jaeger Query Language (JQL) enables programmatic querying and integration with automated alerting systems. The trace comparison feature helps identify performance regressions by highlighting timing differences between trace versions.&lt;/p&gt;
&lt;h3 id=&quot;conclusion-1&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Distributed tracing transforms how you understand and debug microservices. By automatically capturing request flows and timing information, it eliminates the guesswork from performance analysis and incident response. OpenTelemetry provides the standardized instrumentation, OTLP handles reliable transmission, and backends like Jaeger give you the visualization and querying tools to make sense of the data.&lt;/p&gt;
&lt;p&gt;Spring Boot 4's native OpenTelemetry support makes adoption straightforward. Add the starter, configure your exporter, and you're tracing HTTP requests, database queries, and message queues with minimal code. The result is a system where every request tells its own story, complete with timing, dependencies, and errors.&lt;/p&gt;
&lt;p&gt;Start small. Enable tracing in one service, verify the data reaches Jaeger, and gradually expand to your entire application. The visibility you gain will pay dividends the first time you debug a cross-service issue or optimize a slow endpoint. Distributed tracing isn't just a monitoring tool; it's a fundamental shift in how you understand distributed systems.&lt;/p&gt;
&lt;p&gt;For hands-on examples and complete configuration, check out the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/rajkundalia/learning-distributed-tracing&quot;&gt;learning-distributed-tracing&lt;/a&gt; repository.&lt;/p&gt;
&lt;h3 id=&quot;learning-links-&quot;&gt;Learning Links:&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://spring.io/blog/2025/11/18/opentelemetry-with-spring-boot&quot;&gt;https://spring.io/blog/2025/11/18/opentelemetry-with-spring-boot&lt;/a&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/zero-code/java/spring-boot-starter/&quot;&gt;https://opentelemetry.io/docs/zero-code/java/spring-boot-starter/&lt;/a&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://foojay.io/today/spring-boot-4-opentelemetry-explained/&quot;&gt;https://foojay.io/today/spring-boot-4-opentelemetry-explained/&lt;/a&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://last9.io/blog/opentelemetry-for-spring/&quot;&gt;https://last9.io/blog/opentelemetry-for-spring/&lt;/a&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://signoz.io/blog/opentelemetry-spring-boot/&quot;&gt;https://signoz.io/blog/opentelemetry-spring-boot/&lt;/a&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://vorozco.com/blog/2024/2024-11-18-A-practical-guide-spring-boot-open-telemetry.html&quot;&gt;https://vorozco.com/blog/2024/2024-11-18-A-practical-guide-spring-boot-open-telemetry.html&lt;/a&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/cloud-native-daily/how-to-send-traces-from-spring-boot-to-jaeger-229c19f544db&quot;&gt;https://medium.com/cloud-native-daily/how-to-send-traces-from-spring-boot-to-jaeger-229c19f544db&lt;/a&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/xebia-engineering/jaeger-integration-with-spring-boot-application-3c6ec4a96a6f&quot;&gt;https://medium.com/xebia-engineering/jaeger-integration-with-spring-boot-application-3c6ec4a96a6f&lt;/a&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.vinsguru.com/distributed-tracing-in-microservices-with-jaeger/&quot;&gt;https://blog.vinsguru.com/distributed-tracing-in-microservices-with-jaeger/&lt;/a&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://last9.io/blog/distributed-tracing-with-spring-boot/&quot;&gt;https://last9.io/blog/distributed-tracing-with-spring-boot/&lt;/a&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://signoz.io/blog/jaeger-vs-zipkin/&quot;&gt;https://signoz.io/blog/jaeger-vs-zipkin/&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/10841/distributed-tracing-in-spring-boot-a-practical-guide-to-opentelemetry-and-jaeger</guid>
<pubDate>Sat, 31 Jan 2026 18:28:46 +0000</pubDate>
</item>
<item>
<title>OpenTelemetry configuration gotchas</title>
<link>https://coderlegion.com/4719/opentelemetry-configuration-gotchas</link>
<description>&lt;p&gt;Last week, I described &lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.frankel.ch/opentelemetry-tracing-jvm/&quot;&gt;several approaches to OpenTelemetry on the JVM&lt;/a&gt;, their requirements, and their different results. This week, I want to highlight several gotchas found across stacks in the zero-code instrumentation.&lt;/p&gt;
&lt;h3 id=&quot;the-promise-of-opentelemetry&quot;&gt;The promise of OpenTelemetry&lt;/h3&gt;
&lt;p&gt;Since its inception, OpenTelemetry has unified the 3 pillars of observability. In the distributed tracing space, it replaced proprietary protocols Zipkin and Jaeger. IMHO, it achieved such success for several reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;First, a huge industry pressure to work across proprietary tools &lt;/li&gt;
&lt;li&gt;Zero-code instrumentation, allowing developers to be unconcerned by OpenTelemetry&lt;/li&gt;
&lt;li&gt;Easy and unified configuration mechanism via environment variables.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The latter is a boon for the Ops team, as they don't have to know the underlying framework (or stack!) details. They only need to check the &lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/&quot;&gt;Environment Variable Specification&lt;/a&gt; and they are done.&lt;/p&gt;
&lt;p&gt;Here's a simple snippet to illustrate my point:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;environment:
  OTEL_EXPORTER_OTLP_ENDPOINT: http://collector:4317             #1
  OTEL_SERVICE_NAME: foobar                                      #2
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Configure the endpoint to send data to&lt;/li&gt;
&lt;li&gt;Set the component's name&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;From the above, you can't guess what the underlying stack is.&lt;/p&gt;
&lt;p&gt;On the OpenTelemetry side, different developers contribute to different language libraries. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/open-telemetry/opentelemetry-java-instrumentation/graphs/contributors&quot;&gt;Developers of the Java agent&lt;/a&gt; are different from the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/open-telemetry/opentelemetry-python-contrib/graphs/contributors&quot;&gt;those of the Python library&lt;/a&gt;. Moreover, each stack has specific approaches and limitations.&lt;/p&gt;
&lt;p&gt;It naturally creates differences in the different implementations.&lt;/p&gt;
&lt;h3 id=&quot;gotchas&quot;&gt;Gotchas&lt;/h3&gt;
&lt;p&gt;Here are a couple of gotchas I found out, but the list is not exhaustive.&lt;/p&gt;
&lt;h4 id=&quot;path-or-no-path-&quot;&gt;Path or no path?&lt;/h4&gt;
&lt;p&gt;Let's start easy with a gotcha that exists across stacks.&lt;/p&gt;
&lt;p&gt;OpenTelemetry offers some configuration parameters for endpoints.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A generic one, to use for an OpenTelemetry Collector that accepts all signals: &lt;code&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Parameters specialized for a single signal, &lt;em&gt;e.g.&lt;/em&gt;, &lt;code&gt;OTEL_EXPORTER_OTLP_TRACES_ENDPOINT&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In the latter case, one can set the root path to the endpoint, &lt;em&gt;e.g&lt;/em&gt;, &lt;code&gt;http://collector:4317&lt;/code&gt;. The SDK will automatically append the default path, depending on the signal type. For example, it appends &lt;code&gt;/v1/traces&lt;/code&gt; for the &lt;code&gt;OTEL_EXPORTER_OTLP_TRACES_ENDPOINT&lt;/code&gt; configuration parameter.&lt;/p&gt;
&lt;p&gt;Alternatively, one can set the &lt;strong&gt;full&lt;/strong&gt; path to the endpoint, &lt;em&gt;e.g&lt;/em&gt;, &lt;code&gt;http://collector:4317/v1/traces&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The gotcha will catch you when the OpenTelemetry evolves to v2 if you didn't use paths. Because the library will automatically append &lt;code&gt;/v1/&amp;lt;xxx&amp;gt;&lt;/code&gt;, you'll have to make sure the backend offers both &lt;code&gt;/v1&lt;/code&gt; and &lt;code&gt;/v2&lt;/code&gt; endpoints.&lt;/p&gt;
&lt;h4 id=&quot;python-logging&quot;&gt;Python logging&lt;/h4&gt;
&lt;p&gt;To open the list, here's the first gotcha: by default, the Python library doesn't send logging data. It must be enabled explicitly!&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;environment:
  OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED: true         #1
  OTEL_EXPORTER_OTLP_ENDPOINT: http://collector:4317
  OTEL_SERVICE_NAME: foobar
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Enable logs&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Developers also must be involved:&lt;/p&gt;
&lt;p&gt;&amp;gt;Unlike Traces and Metrics, there is no equivalent Logs API. There is only an SDK. For Python, you use the Python &lt;code&gt;logger&lt;/code&gt; library, and then the OTel SDK attaches an OTLP handler to the root logger, turning the Python logger into an OTLP logger. One way to accomplish this is documented in the logs example in the OpenTelemetry Python repository.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&amp;gt;-- &lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/zero-code/python/logs-example/&quot;&gt;Logs Auto-Instrumentation Example&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;micrometer-tracing&quot;&gt;Micrometer Tracing&lt;/h3&gt;
&lt;p&gt;Before OpenTelemetry, Jaeger and Zipkin reigned supreme in the distributed tracing area. In the great Spring tradition, the project created Spring Cloud Sleuth to offer a facade over Zipkin. Over time, it evolved to be compatible with OpenTracing, one of OpenTelemetry's parents, along with OpenCensus.&lt;/p&gt;
&lt;p&gt;I cannot be sure whether OpenTelemetry was the cause, but Spring Cloud Sleuth evolved into the (badly-named) &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.micrometer.io/tracing/reference/&quot;&gt;Micrometer Tracing&lt;/a&gt; library. Micrometer Tracing &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.micrometer.io/tracing/reference/tracers.html&quot;&gt;targets Zipkin first&lt;/a&gt;, but also supports OpenTelemetry. I find adding the requested libraries a bit complex, but it's manageable.&lt;/p&gt;
&lt;p&gt;However, the configuration doesn't conform to the OpenTelemetry environment variables. It brings its variables:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;environment:
  MANAGEMENT_OTLP_TRACING_ENDPOINT: http://jaeger:4318/v1/traces #1-2
  OTEL_SERVICE_NAME: foobar                                      #3
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Non-OpenTelemetry environment variable name&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MUST&lt;/strong&gt; set the full path&lt;/li&gt;
&lt;li&gt;Conform to the OpenTelemetry spec since &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/spring-projects/spring-boot/pull/44394&quot;&gt;Spring Boot 3.5&lt;/a&gt;. Before it, it used &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.spring.io/spring-boot/appendix/application-properties/index.html#application-properties.core.spring.application.name&quot;&gt;spring.application.name&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;quarkus&quot;&gt;Quarkus&lt;/h3&gt;
&lt;p&gt;Compare with &lt;a rel=&quot;nofollow&quot; href=&quot;https://quarkus.io/guides/opentelemetry&quot;&gt;Quarkus&lt;/a&gt;, which prefixes regular OpenTelemetry environment variables with &lt;code&gt;QUARKUS_&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;environment:
  QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: http://jaeger:4317
  QUARKUS_OTEL_SERVICE_NAME: foobar
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It's consistent. And yet, the Quarkus instrumentation has another gotcha:&lt;/p&gt;
&lt;p&gt;&amp;gt;Only the &lt;strong&gt;tracing&lt;/strong&gt; signal is enabled by default. To enable &lt;strong&gt;metrics&lt;/strong&gt; and &lt;strong&gt;logs&lt;/strong&gt;, add the following configuration to your application.properties file:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&amp;gt;-- &lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/zero-code/java/quarkus/&quot;&gt;Quarkus instrumentation&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Meanwhile, if you're an expert at operating OpenTelemetry, these defaults will surprise you. In OpenTelemetry, they are governed by other configuration parameters:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;environment:
  OTEL_METRICS_EXPORTER: none
  OTEL_LOGS_EXPORTER: none
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;summary&quot;&gt;Summary&lt;/h3&gt;
&lt;p&gt;OpenTelemetry has become a &lt;em&gt;de facto&lt;/em&gt; standard in a few years. However, the promise of ubiquitous configuration, if there was ever such a thing, doesn't hold. Operators of OpenTelemetry can't treat services as black boxes. They must consider the underlying stack and framework, and learn how to configure them accordingly.&lt;/p&gt;
&lt;p&gt;On the JVM, I'd recommend to stick to the Java Agent as much as possible, but if you must across stacks, there's no magical solution.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;To go further:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html&quot;&gt;Maven - Introduction to the Dependency Mechanism&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://effective-rust.com/dep-graph.html&quot;&gt;Effective Rust - Item 25: Manage your dependency graph&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.frankel.ch/opentelemetry-gotchas/&quot;&gt;A Java Geek&lt;/a&gt; on August 10&lt;sup&gt;th&lt;/sup&gt;, 2025&lt;/em&gt;&lt;/p&gt;
</description>
<category>Monitoring &amp; Logging</category>
<guid isPermaLink="true">https://coderlegion.com/4719/opentelemetry-configuration-gotchas</guid>
<pubDate>Thu, 14 Aug 2025 11:19:18 +0000</pubDate>
</item>
<item>
<title>OpenTelemetry Tracing on the JVM</title>
<link>https://coderlegion.com/4498/opentelemetry-tracing-on-the-jvm</link>
<description>&lt;p&gt;You may know I'm a big fan of OpenTelemetry. I recently finished developing a &lt;a rel=&quot;nofollow&quot; href=&quot;https://yowcon.com/melbourne-2025/masterclasses/560/gain-practical-in-depth-experience-with-observability-using-opentelemetry&quot;&gt;master class for the YOW! conference&lt;/a&gt; at the end of the year. During development, I noticed massive differences in configuration and results across programming languages. Even worse, differences exist across frameworks inside the same programming language.&lt;/p&gt;
&lt;p&gt;In this post, I want to compare the different zero-code OpenTelemetry approaches on the JVM, covering the most widespread:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Spring Boot with Micrometer Tracing&lt;/li&gt;
&lt;li&gt;Spring Boot with the OpenTelemetry Agent &lt;/li&gt;
&lt;li&gt;OpenTelemetry Spring Boot Starter&lt;/li&gt;
&lt;li&gt;Quarkus&lt;/li&gt;
&lt;li&gt;Quarkus with the OpenTelemetry Agent&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;commonalities&quot;&gt;Commonalities&lt;/h3&gt;
&lt;p&gt;I keep the architecture pretty simple:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=11040439081269176861&quot; alt=&quot;Architecture overview&quot;&gt;&lt;/p&gt;
&lt;p&gt;I'm using Reactive data access on both the remote service and the database to spice up things a bit, more specifically, Kotlin coroutines. Here's the general structure:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;val products = coroutineScope {
    val ping = async {
        // Call the ping service
    }
    val products = async {
        // Query the database
    }
    println(&quot;Received ping response: ${ping.await()}&quot;)
    products.await()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here are the features for each stack:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Quarkus &lt;/th&gt;&lt;th&gt;Spring Boot&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Web &lt;/td&gt;&lt;td&gt;Mutiny &lt;/td&gt;&lt;td&gt;WebFlux&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;HTTP client &lt;/td&gt;&lt;td&gt;REST client &lt;/td&gt;&lt;td&gt;&lt;code&gt;WebClient&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database access pattern &lt;/td&gt;&lt;td&gt;Record &lt;/td&gt;&lt;td&gt;Repository&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Database access &lt;/td&gt;&lt;td&gt;Hibernate Reactive with Panache &lt;/td&gt;&lt;td&gt;R2DBC&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;running-the-opentelemetry-agent&quot;&gt;Running the OpenTelemetry Agent&lt;/h3&gt;
&lt;p&gt;The OpenTelemetry Java Agent is the first approach I used regarding OpenTelemetry.&lt;/p&gt;
&lt;p&gt;The only necessary configuration is to set the agent when running the JVM:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;java -javaagent:opentelemetry-javaagent.jar -jar otel-boot-agent.jar
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The agent supports &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md&quot;&gt;lots of frameworks and libraries&lt;/a&gt;, including Spring Boot, Quarkus, Ktor, Spark, and many others. When the application flow finds a supported framework/library, it logs a span.&lt;/p&gt;
&lt;p&gt;The Agent upholds the standard OpenTelemetry environment variables.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  otel-boot-agent:
    build: otel-boot-agent
    environment:
      OTEL_SERVICE_NAME: OTEL Boot Agent                             #1
      OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318                #2
      OTEL_METRICS_EXPORTER: none                                    #3
      OTEL_LOGS_EXPORTER: none                                       #3
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;OpenTelemetry service name&lt;/li&gt;
&lt;li&gt;OpenTelemetry endpoint; Spring Boot uses HTTP&lt;/li&gt;
&lt;li&gt;Neither metrics nor logs&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Here's the Jaeger trace when calling the endpoint on the Spring Boot application:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=2041424122883509039&quot; alt=&quot;Trace of Spring Boot with OpenTelemetry Agent&quot;&gt;&lt;/p&gt;
&lt;p&gt;And here's the one on Quarkus:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=15781423208460660859&quot; alt=&quot;Trace of Quarkus with OpenTelemetry Agent&quot;&gt;&lt;/p&gt;
&lt;p&gt;Spring Boot features an additional span that displays the repository call. There's no such thing available on Quarkus, as I'm using the Record pattern to access the data.&lt;/p&gt;
&lt;p&gt;The Agent outputs the SQL query in both frameworks, &lt;em&gt;i.e.&lt;/em&gt;, &lt;code&gt;SELECT product.* FROM product&lt;/code&gt;. &lt;strong&gt;The Java Agent works out-of-the-box&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 id=&quot;micrometer-tracing-on-spring-boot&quot;&gt;Micrometer Tracing on Spring Boot&lt;/h3&gt;
&lt;p&gt;Spring Boot provides dedicated OpenTelemetry support via &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.micrometer.io/tracing/reference/&quot;&gt;Micrometer Tracing&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&amp;gt;Micrometer Tracing provides a simple facade for the most popular tracer libraries, letting you instrument your JVM-based application code without vendor lock-in. It is designed to add little to no overhead to your tracing collection activity while maximizing the portability of your tracing effort.&lt;/p&gt;
&lt;p&gt;Besides the Micrometer Tracing dependency itself, you need additional ones:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The Spring Boot Actuator&lt;/li&gt;
&lt;li&gt;A bridge–OpenTelemetry&lt;/li&gt;
&lt;li&gt;An exporter–OpenTelemetry as well&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Configuration doesn't follow the OpenTelemetry standard:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  otel-boot-micrometer:
    environment:
      SPRING_APPLICATION_NAME: OTEL Micrometer                       #1-2
      MANAGEMENT_OTLP_TRACING_ENDPOINT: http://jaeger:4318/v1/traces #1-3
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Different values from the OpenTelemetry specification&lt;/li&gt;
&lt;li&gt;The Spring application name serves as the OpenTelemetry service name&lt;/li&gt;
&lt;li&gt;Full path to the API endpoint&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The above setup doesn't register the database call. To fix it, we need an additional dependency: &lt;a rel=&quot;nofollow&quot; href=&quot;https://r2dbc.io/r2dbc-proxy/docs/current-snapshot/docs/html/&quot;&gt;R2DBC Proxy&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The new trace contains the database span:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=10716697642535292646&quot; alt=&quot;Trace of Quarkus with OpenTelemetry Agent&quot;&gt;&lt;/p&gt;
&lt;p&gt;You might notice another issue: calls to the service and the database are sequential, where they should be parallel. It stems from Spring Boot not handling context propagation properly to the coroutine scope. It's an underlying work from the Spring team. Subscribe to the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/spring-projects/spring-framework/issues/35185&quot;&gt;GitHub issue&lt;/a&gt; if you're interested.&lt;/p&gt;
&lt;h3 id=&quot;opentelemetry-spring-boot-starter&quot;&gt;OpenTelemetry Spring Boot Starter&lt;/h3&gt;
&lt;p&gt;The OpenTelemetry project provides a &lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/zero-code/java/spring-boot-starter/&quot;&gt;Spring Boot starter&lt;/a&gt;. You need only a single dependency, and like other starters, Spring Boot magic takes care of configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;dependency&amp;gt;
    &amp;lt;groupId&amp;gt;io.opentelemetry.instrumentation&amp;lt;/groupId&amp;gt;
    &amp;lt;artifactId&amp;gt;opentelemetry-spring-boot-starter&amp;lt;/artifactId&amp;gt;
&amp;lt;/dependency&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=15280324090093701492&quot; alt=&quot;Trace of Spring Boot with the OpenTelemetry Starter&quot;&gt;&lt;/p&gt;
&lt;p&gt;The result is very similar to the previous one, including the not-parallel-but-serial issue.&lt;/p&gt;
&lt;h3 id=&quot;quarkus-1&quot;&gt;Quarkus&lt;/h3&gt;
&lt;p&gt;We saw the results of using the OpenTelemetry Agent in the first section. It's quite straightforward to use OpenTelemetry without the Agent; you need a single dependency:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;dependency&amp;gt;
    &amp;lt;groupId&amp;gt;io.quarkus&amp;lt;/groupId&amp;gt;
    &amp;lt;artifactId&amp;gt;quarkus-opentelemetry&amp;lt;/artifactId&amp;gt;
&amp;lt;/dependency&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Quarkus prefixes regular OpenTelemetry environment variable names with &lt;code&gt;QUARKUS_&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  otel-quarkus:
    environment:
      QUARKUS_OTEL_SERVICE_NAME: OTEL Quarkus                        #1
      QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: http://jaeger:4317 #2
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;OpenTelemetry service name&lt;/li&gt;
&lt;li&gt;OpenTelemetry endpoint; Quarkus uses gRPC&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Results are as expected:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=6128337141775866164&quot; alt=&quot;Trace of Quarkus&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;discussion&quot;&gt;Discussion&lt;/h3&gt;
&lt;p&gt;OpenTelemetry approaches vary widely in both configuration and results. Unless you're prevented from using Java agents for technical or organizational reasons, I recommend using the OpenTelemetry Agent first. It handles everything you can throw at it out of the box, including the most common libraries. Barring that, you need deep knowledge of the stack you're using, lest results don't represent what happens in reality.&lt;/p&gt;
&lt;p&gt;The complete source code for this post can be found on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ajavageek/otel-jvm&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;To go further:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/zero-code/java/agent/&quot;&gt;OpenTelemetry Java Agent&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.frankel.ch/kotlin-coroutines-otel-tracing/&quot;&gt;Kotlin Coroutines and OpenTelemetry tracing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/spring-projects/spring-framework/issues/35185&quot;&gt;Add support for Micrometer context propagation in Kotlin coroutines&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.frankel.ch/opentelemetry-tracing-jvm/&quot;&gt;A Java Geek&lt;/a&gt; on August 3&lt;sup&gt;rd&lt;/sup&gt;, 2025&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/4498/opentelemetry-tracing-on-the-jvm</guid>
<pubDate>Thu, 07 Aug 2025 17:24:20 +0000</pubDate>
</item>
<item>
<title>Even more OpenTelemetry - Kubernetes special</title>
<link>https://coderlegion.com/1830/even-more-opentelemetry-kubernetes-special</link>
<description>&lt;p&gt;I have presented my &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nfrankel/opentelemetry-tracing&quot;&gt;OpenTelemetry demo&lt;/a&gt; many times, and I still do. Each time, the audience is different. To make no two presentations the same, I always ask attendees what stack they are more interested in. I also &lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.frankel.ch/improve-otel-demo/&quot;&gt;regularly add&lt;/a&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.frankel.ch/even-more-opentelemetry/&quot;&gt;new features&lt;/a&gt; for the same reason.&lt;/p&gt;
&lt;p&gt;I was a victim of the IT crisis last summer, and my company fired me, so I no longer work on Apache APISIX. They say that the Chinese ideogram for crisis contains the ideogram for opportunity. I used this opportunity to join &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.loft.sh/&quot;&gt;LoftLabs&lt;/a&gt;. LoftLabs offers a cluster virtualization solution called &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.vcluster.com/&quot;&gt;vCluster&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Hence, this batch of changes to the demo has a lot of Kubernetes-related features.&lt;/p&gt;
&lt;h3 id=&quot;from-docker-compose-to-helm&quot;&gt;From Docker Compose to Helm&lt;/h3&gt;
&lt;p&gt;Docker Compose is great for demos: &lt;code&gt;docker compose up&lt;/code&gt;, and you're good to go, but I know no organization that uses it in production. Deploying workloads to Kubernetes is much more involved than that. I've used Kubernetes for demos in the past; typing &lt;code&gt;kubectl apply -f&lt;/code&gt; is dull fast. In addition to GitOps, which isn't feasible for demos, the two main competitors are &lt;a rel=&quot;nofollow&quot; href=&quot;https://helm.sh/&quot;&gt;Helm&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://kustomize.io/&quot;&gt;Kustomize&lt;/a&gt;. I chose the former for its ability to add dependencies.&lt;/p&gt;
&lt;h3 id=&quot;helm-subcharts-for-the-win-&quot;&gt;Helm subcharts for the win!&lt;/h3&gt;
&lt;p&gt;I started to migrate by creating my own Helm templates for infrastructure. While it worked, it was hugely inefficient. In the end, I used the following Helm Charts as sub-charts in my infra chart:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/bitnami/charts/tree/main/bitnami/valkey&quot;&gt;Valkey&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/traefik/traefik-helm-chart&quot;&gt;Traefik&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/open-telemetry/opentelemetry-helm-charts&quot;&gt;OpenTelemetry Collector&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/open-telemetry/opentelemetry-helm-charts&quot;&gt;OpenTelemetry Operator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/jaegertracing/helm-charts&quot;&gt;Jaeger&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/bitnami/charts/tree/main/bitnami/postgresql&quot;&gt;PostgreSQL&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;At the start, I kept a custom Jaeger manifest because the official Jaeger Helm Chart doesn't provide a way to expose the UI outside the cluster. Then, I realized that I was using Traefik and that I could use it. It's precisely the architecture I used on Docker Compose with Apache APISIX.&lt;/p&gt;
&lt;p&gt;I did keep a custom-made manifest for Mosquitto, but that's because the official one doesn't add benefit IMHO: you need a config file.&lt;/p&gt;
&lt;h3 id=&quot;kubernetes-topology&quot;&gt;Kubernetes topology&lt;/h3&gt;
&lt;p&gt;Besides OpenTelemetry, I want to highlight the capabilities of vCluster. For this reason, I separate the different Docker Compose services into two groups: an &lt;em&gt;infrastructure&lt;/em&gt; group (Traefik, Jaeger, Valkey, Mosquitto, and an OpenTelemetry Collector) and an &lt;em&gt;app&lt;/em&gt; group (PostgreSQL, Mosquitto, the OTEL Operator, an OTEL collector, and all apps). I deploy infrastructure-related pods on the host cluster and the app pods in a dedicated virtual cluster's &lt;code&gt;default&lt;/code&gt; namespace.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;helm upgrade --install vcluster vcluster/vcluster --namespace otel --values helm/vcluster.yaml #1
helm upgrade --install otel-infra infra --values helm/infra/values.yaml --namespace otel       #2
vcluster connect vcluster                                                                      #3
helm upgrade --install otel-apps apps --values helm/apps/values.yaml                           #4
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Create the virtual cluster in the &lt;code&gt;otel&lt;/code&gt; namespace&lt;/li&gt;
&lt;li&gt;Deploy infrastructure pods in the &lt;code&gt;otel&lt;/code&gt; namespace on the host cluster&lt;/li&gt;
&lt;li&gt;Connect to the virtual cluster. I'm now in a completely isolated Kubernetes cluster. Only we know it's a virtual one.&lt;/li&gt;
&lt;li&gt;Schedule infrastructure pods in the &lt;code&gt;default&lt;/code&gt; namespace of the &lt;code&gt;vcluster&lt;/code&gt; virtual cluster&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;traefik-as-an-ingress-controller&quot;&gt;Traefik as an Ingress Controller&lt;/h3&gt;
&lt;p&gt;When I stopped working on APISIX, I decided to see how API Gateways worked and migrated to &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.krakend.io/&quot;&gt;KrakenD&lt;/a&gt; on Docker Compose. For basic usage, it's simple enough.&lt;/p&gt;
&lt;p&gt;I wanted to try another one on Kubernetes. I chose &lt;a rel=&quot;nofollow&quot; href=&quot;https://traefik.io/&quot;&gt;Traefik&lt;/a&gt; because my searches mentioned it was the easiest to use in Kubernetes. As I mentioned, Traefik provides a Helm Chart, which makes it easy to install. Additionally, it integrates with OpenTelemetry.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;traefik:
  fullnameOverride: traefik
  deployment:
    replicas: 1
  gateway:
    enabled: false
  rbac:
    enabled: true
  ports:
    web:
      nodePort: 30080
  tracing:
    otlp:
      enabled: true                    #1
      grpc:
        enabled: true                  #2
        endpoint: collector:4317       #3
        insecure: true                 #4
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Enable OpenTelemetry&lt;/li&gt;
&lt;li&gt;Prefer gRPC over HTTP&lt;/li&gt;
&lt;li&gt;Set the collector endpoint&lt;/li&gt;
&lt;li&gt;Security is not the point of the demo&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;exposing-services-and-the-ingress-class&quot;&gt;Exposing services and the Ingress class&lt;/h3&gt;
&lt;p&gt;The idea of scheduling infrastructure pods on the host cluster and apps pods in the virtual cluster is excellent from an architectural point of view, but how do the latter access the former? You probably use &lt;code&gt;Service&lt;/code&gt; to access pods inside the same cluster. Services provide a stable IP over pods that come and go. Across regular clusters, you'll need an Ingress Controller or a Gateway API implementation. vCluster offers a straightforward alternative.&lt;/p&gt;
&lt;p&gt;In the demo, I must:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Access services on the host cluster from pods scheduled on the virtual cluster&lt;/li&gt;
&lt;li&gt;Configure Ingresses on the virtual cluster with an Ingress class whose Ingress Controller is deployed on the host cluster&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;As seen above, we can configure both when we create the virtual cluster.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;networking:
  replicateServices:
    fromHost:
      - from: otel/valkey-primary      #1
        to: default/cache
      - from: otel/jaeger              #1
        to: default/tracing
      - from: otel/traefik             #1
        to: default/ingress
      - from: otel/mosquitto           #1
        to: default/messages
sync:
  toHost:
    ingresses:
      enabled: true                    #2
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Synchronize a &lt;code&gt;Service&lt;/code&gt; &lt;strong&gt;from&lt;/strong&gt; the host cluster &lt;strong&gt;to&lt;/strong&gt; the virtual cluster. The pattern is &lt;code&gt;&amp;lt;namespace&amp;gt;.&amp;lt;name&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Ingresses deployed on the virtual cluster are collected on the Ingress Controller configured on the host cluster&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;a-touch-of-the-opentelemetry-collector&quot;&gt;A touch of the OpenTelemetry collector&lt;/h3&gt;
&lt;p&gt;I chose not to use an OpenTelemetry Collector in the Docker Compose setup because it didn't add anything. With the new two-cluster architecture, it was beneficial to introduce one per cluster.&lt;/p&gt;
&lt;p&gt;At first, I configured each Collector to add a custom tag regarding the cluster type, &lt;code&gt;host&lt;/code&gt; or &lt;code&gt;virtual&lt;/code&gt;. Then, I discovered that the Collector Helm Chart provided a Kubernetes preset.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;opentelemetry-collector:
  image:
    repository: &quot;otel/opentelemetry-collector-k8s&quot;
  fullnameOverride: collector
  enabled: true
  mode: deployment
  presets:
    kubernetesAttributes:
      enabled: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It automatically adds Kubernetes-related data. Here's the result in one of the app pods:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Key  &lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;k8s.deployment.name&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;warehouse-uk&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;k8s.namespace.name&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;default&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;k8s.node.name&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;orbstack&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;k8s.pod.ip&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;192.168.194.44&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;k8s.pod.name&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;warehouse-uk-5965658cfd-sbkgm&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;k8s.pod.start_time&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;2024-11-29T16:36:30Z&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;k8s.pod.uid&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;b6ba838e-621c-46b1-ac32-c9225c80e142&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;initializing-postgresql-with-data&quot;&gt;Initializing PostgreSQL with data&lt;/h3&gt;
&lt;p&gt;The &quot;official&quot; &lt;a rel=&quot;nofollow&quot; href=&quot;https://hub.docker.com/_/postgres&quot;&gt;PostgreSQL Docker image&lt;/a&gt; allows initializing the database via SQL scripts. You only need to mount the scripts folder to the container's &lt;code&gt;/docker-entrypoint-initdb.d&lt;/code&gt; folder. When the container starts, it will run these scripts in alphabetical order. On Kubernetes, I started to write all the scripts' content in a static &lt;code&gt;ConfigMap&lt;/code&gt;, but I didn't find this approach maintainable.&lt;/p&gt;
&lt;p&gt;Given I'm using a Helm Chart, I thought I could find a better way, and I did. I put all the scripts in a dedicated &lt;code&gt;files/sql&lt;/code&gt; folder in the Helm Chart. Then, I wrote the following template to concatenate them in a &lt;code&gt;ConfigMap&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: v1
kind: ConfigMap
metadata:
  name: postgres-init-scripts
  namespace: {{ .Release.Namespace }}
data:
{{- $files := .Files.Glob &quot;files/sql/*.sql&quot; }}    #1
{{- range $path, $content := $files }}            #2
  {{ base $path }}: &amp;gt;                             #3
{{ $content | toString | indent 4 }}              #4
{{- end }}
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;List all files under the &lt;code&gt;files/sql/&lt;/code&gt; folder&lt;/li&gt;
&lt;li&gt;Iterate through all files&lt;/li&gt;
&lt;li&gt;Write the file name&lt;/li&gt;
&lt;li&gt;Write the file's content&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;At this point, we can use the &lt;code&gt;ConfigMap&lt;/code&gt; in the global Helm file under the PostgreSQL section:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;postgresql:
  primary:
    persistence:
      enabled: false
    initdb:
      scriptsConfigMap: postgres-init-scripts
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;kubernetes-instrumentation-of-pods&quot;&gt;Kubernetes instrumentation of pods&lt;/h3&gt;
&lt;p&gt;I recently learned that Kubernetes could instrument pods running certain technologies with &lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/kubernetes/operator/automatic/&quot;&gt;OpenTelemetry&lt;/a&gt;. I wanted to try it out, so I added a new OpenTelemetry-free Python-based image to the overall architecture. It's a two-step process:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Install the &lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/kubernetes/operator/&quot;&gt;OpenTelemetry Operator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Apply an &lt;code&gt;Instrumentor&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt; apiVersion: opentelemetry.io/v1alpha1&lt;br&gt;
 kind: Instrumentation&lt;br&gt;
 metadata:&lt;br&gt;
   name: demo-instrumentation&lt;br&gt;
 spec:&lt;br&gt;
   exporter:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; endpoint: http://collector:4318               #1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;   propagators:                                    #2&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; - tracecontext
 - baggage
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;   sampler:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; type: always_on                               #3
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Configure the OpenTelemetry collector endpoint&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;Set the propagators&lt;/li&gt;
&lt;li&gt;I want all the traces for my demo; in production environments, you'd probably want to sample them&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;At this point, when we schedule pods, Kubernetes will pass them through a new mutating webhook. If the pod has annotations that the instrumentation recognizes, the latter will add a sidecar that instruments the main pod with OpenTelemetry. For the Python app I mentioned above, it looks like the following:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: apps/v1
kind: Deployment
metadata:
  name: recommendations
  labels:
    app: recommendations
spec:
  replicas: 1
  selector:
    matchLabels:
      app: recommendations
  template:
    metadata:
      annotations:
        instrumentation.opentelemetry.io/inject-python: &quot;true&quot;     #1
      labels:
        app: recommendations
    spec:
      containers:
        - name: recommendations
          image: recommendations:1.0
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;The mutating webhook instruments the pod with the Python-specific image&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Here's a screenshot of the resulting traces:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=12663518132210274371&quot; alt=&quot;Kubernetes-instrumented Python app traces&quot;&gt;&lt;/p&gt;
&lt;p&gt;What's impressive is that it's all based on Kubernetes. Neither the app developer nor the container build had to do anything related to OpenTelemetry.&lt;/p&gt;
&lt;h3 id=&quot;a-new-quarkus-component&quot;&gt;A new Quarkus component&lt;/h3&gt;
&lt;p&gt;This last section is unrelated to Kubernetes. I'm already using Spring in two components for the demo, one instrumented with the OpenTelemetry Java agent and the other with Micrometer Tracing and compiled with GraalVM native. I wanted to demo the OpenTelemetry integration capabilities of Quarkus.&lt;/p&gt;
&lt;p&gt;You only need two dependencies for auto-instrumentation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;dependency&amp;gt;
    &amp;lt;groupId&amp;gt;io.quarkus&amp;lt;/groupId&amp;gt;
    &amp;lt;artifactId&amp;gt;quarkus-opentelemetry&amp;lt;/artifactId&amp;gt;
&amp;lt;/dependency&amp;gt;
&amp;lt;dependency&amp;gt;
    &amp;lt;groupId&amp;gt;io.opentelemetry.instrumentation&amp;lt;/groupId&amp;gt;
    &amp;lt;artifactId&amp;gt;opentelemetry-jdbc&amp;lt;/artifactId&amp;gt;
&amp;lt;/dependency&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can customize the traces with annotations:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; @Path(&quot;/stocks&quot;)
 @Produces(MediaType.APPLICATION_JSON)
 public class StockLevelResource {
 
     private final StockLevelRepository repository;
 
     @Inject
     public StockLevelResource(StockLevelRepository repository) {
         this.repository = repository;
     }
 
     @GET
     @Path(&quot;/{id}&quot;)
     @WithSpan                                                      //1
     public List&amp;lt;StockLevel&amp;gt; stockLevels(@PathParam(&quot;id&quot;) @SpanAttribute(&quot;id&quot;) Long id) { //2
         return repository.findByProductId(id);
     }
 }
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Add a new span whose value is the method's name&lt;/li&gt;
&lt;li&gt;Add a new &lt;code&gt;id&lt;/code&gt; attribute whose value is the &lt;code&gt;id&lt;/code&gt;'s value at runtime&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=3203880188751315472&quot; alt=&quot;Quarkus app traces&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;summary-1&quot;&gt;Summary&lt;/h3&gt;
&lt;p&gt;I added a couple of new features to my existing OpenTelemetry demo:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Migrating from Docker Compose to Kubernetes&lt;/li&gt;
&lt;li&gt;Installing via Helm Charts&lt;/li&gt;
&lt;li&gt;Using vCluster to isolate the app pods from the infrastructure pods&lt;/li&gt;
&lt;li&gt;Using Traefik as an Ingress Controller&lt;/li&gt;
&lt;li&gt;Installing an OpenTelemetry collector instance in each cluster&lt;/li&gt;
&lt;li&gt;Instrumenting an OpenTelemetry-free app with the Kubernetes Instrumentation&lt;/li&gt;
&lt;li&gt;Adding a new Quarkus application&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I'm always happy to add new components that feature a new stack. At the moment, I'm thinking .Net is missing from the landscape. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/nfrankel/opentelemetry-tracing&quot;&gt;Suggestions and PRs&lt;/a&gt; are welcome!&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;To go further:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://helm.sh/&quot;&gt;Helm&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/kubernetes/helm/collector/&quot;&gt;OpenTelemetry Collector Chart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/kubernetes/helm/operator/&quot;&gt;OpenTelemetry Operator Chart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://opentelemetry.io/docs/kubernetes/operator/automatic/&quot;&gt;Injecting Auto-instrumentation on Kubernetes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.vcluster.com/&quot;&gt;vCluster&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.vcluster.com/docs/vcluster/introduction/what-are-virtual-clusters&quot;&gt;What are virtual clusters?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://doc.traefik.io/traefik/&quot;&gt;Traefik&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://doc.traefik.io/traefik/providers/kubernetes-ingress/&quot;&gt;Traefik &amp;amp; Kubernetes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://quarkus.io/guides/opentelemetry&quot;&gt;&quot;Quarkus: Using OpenTelemetry&quot;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.frankel.ch/even-more-opentelemetry-kubernetes/&quot;&gt;A Java Geek&lt;/a&gt; on April 6&lt;sup&gt;th&lt;/sup&gt;, 2025&lt;/em&gt;&lt;/p&gt;
</description>
<category>Site Reliability Engineering</category>
<guid isPermaLink="true">https://coderlegion.com/1830/even-more-opentelemetry-kubernetes-special</guid>
<pubDate>Thu, 10 Apr 2025 07:29:21 +0000</pubDate>
</item>
</channel>
</rss>