# base14 Scout
> Reduce downtime drastically!
This file contains all documentation content in a single document following the llmstxt.org standard.
## Getting Started with base14 Scout, OpenTelemetry Native Observability Platform
Pick the path that matches your environment and goals. Each track is designed
for a quick win and a clear next step.
### I want a 5-minute setup
- [5-Minute Quick Start](/guides/quick-start)
- [Hello World - Send Your First Trace](/instrument/apps/hello-world)
### I am deploying a collector
- [Docker Compose](/instrument/collector-setup/docker-compose-example)
- [Kubernetes Helm](/instrument/collector-setup/kubernetes-helm-setup)
- [OpenTelemetry Operator](/instrument/collector-setup/opentelemetry-operator-setup)
- [Linux](/instrument/collector-setup/linux-setup)
- [Otel Binary](/instrument/collector-setup/otel-collector-binary-example)
### I need application instrumentation
- [Auto-instrumentation overview](/instrument/apps/auto-instrumentation)
- [Custom instrumentation overview](/instrument/apps/custom-instrumentation)
- [Node.js](/instrument/apps/auto-instrumentation/nodejs)
- [Go](/instrument/apps/auto-instrumentation/go)
- [Java](/instrument/apps/custom-instrumentation/java)
- [Python](/instrument/apps/auto-instrumentation/fast-api)
### I need infrastructure telemetry
- [AWS ECS](/instrument/collector-setup/ecs-setup)
- [Kubernetes](/instrument/collector-setup/kubernetes-helm-setup)
- [Docker](/instrument/collector-setup/docker-compose-example)
### I want to use the CLI
- [Install Scout CLI](/scout-cli/installation)
- [Generate an OTel Collector config](/scout-cli/otel-config/config-init)
- [Validate a collector config](/scout-cli/otel-config/config-validate)
- [Query logs, traces, and metrics](/scout-cli/scout-access/logs)
### I want CI/CD observability
- [GitHub Actions Observability](/guides/cicd-observability/github-actions-observability)
### I want to explore data in Scout
- [traceX](/operate/tracex)
- [LogX](/operate/logx)
- [Create Your First Dashboard](/guides/create-your-first-dashboard)
---
## AI Agent Observability with OpenTelemetry - Tracing Multi-Agent Systems and Tool Calls
## AI Agent Observability
Instrument AI agents with OpenTelemetry to trace the **whole conversation** -
model calls, tool invocations, agent handoffs, and the downstream API and
database work each decision triggers - in a single correlated view.
An agent is not a single LLM call. It is a loop: the model reasons, calls a
tool, reads the result, calls another agent, and eventually answers. When that
loop misbehaves - a tool times out, a sub-agent returns garbage, a retry storm
burns tokens - the LLM is rarely the root cause. The failure lives in the tool
call, the handoff, or the downstream service. To debug it you need telemetry
that spans the entire agent execution, not just the chat completion.
This guide is the framework-agnostic companion to the language-specific
[LLM Observability](./llm-observability.md) guides. It focuses on the **agent
layer**: the spans, attributes, and conventions that turn a scattered set of
LLM calls into a coherent agent timeline. Code samples use the OpenTelemetry
Python SDK, but every pattern is portable - the attribute names are the
[OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/),
so the same telemetry works with any OpenTelemetry backend, including base14
Scout.

:::tip TL;DR
Bind every span an agent produces - LLM calls, tool calls, sub-agent handoffs,
and downstream HTTP and database work - with three attributes:
`gen_ai.conversation.id`, `gen_ai.agent.name`, and `gen_ai.operation.name`.
Emit `invoke_agent` spans for agent steps and `execute_tool` spans for tool
calls, and thread the conversation ID through the whole call stack. The result
is a single agent timeline in base14 Scout that shows exactly where an agent
run spent time and where it failed.
:::
:::note Running this in production
Storing and querying these traces at production volume is what base14 Scout
does.
[Check out Scout LLM Observability](https://base14.io/scout/llm-observability).
:::
### AI Agent Observability Overview
This guide shows you how to:
- Model an agent run as a conversation-level timeline, not isolated LLM calls
- Use the GenAI agent operation types (`invoke_agent`, `execute_tool`,
`create_agent`, `chat`)
- Bind every span in a run with `gen_ai.conversation.id` and propagate it to
downstream HTTP, database, and queue work
- Instrument tool calls with the `gen_ai.tool.*` attributes and proper error
handling
- Trace Model Context Protocol (MCP) tool calls as `execute_tool` spans
- Trace multi-agent orchestration and handoffs so each agent gets its own
swim lane
- Record agent metrics and evaluation results for cost, latency, and quality
- Handle content capture, PII, and the experimental semantic-convention opt-in
### Who This Guide Is For
This documentation is designed for:
- **AI engineers**: building tool-using or multi-agent systems and needing to
see why a run was slow, expensive, or wrong
- **Backend developers**: embedding agents into existing services and wanting
agent spans correlated with HTTP and database telemetry
- **Platform teams**: standardizing agent observability across frameworks
(LangGraph, custom orchestration, MCP servers) on one open standard
- **DevOps and SRE**: operating agents in production with cost, error-rate, and
quality alerting
### Prerequisites
This guide builds on the base OpenTelemetry setup covered elsewhere. Before
starting, ensure you have:
- A working OpenTelemetry SDK setup (tracer and meter providers, OTLP
exporter). See [LLM Observability](./llm-observability.md) for the Python
bootstrap, or the [Rust](./rust-llm-observability.md) and
[Java](./spring-ai-llm-observability.md) guides for those stacks.
- **Scout Collector** configured and reachable from your application
- See
[Docker Compose Setup](../../instrument/collector-setup/docker-compose-example.md)
for local development
- See
[Kubernetes Helm Setup](../../instrument/collector-setup/kubernetes-helm-setup.md)
for production
- Auto-instrumentation for your web framework and HTTP client, so downstream
work joins the trace automatically. See
[Auto-Instrumentation](../../instrument/apps/auto-instrumentation/index.md).
- Familiarity with GenAI spans for LLM calls (the `chat` operation). This guide
extends that model to the agent and tool layers.
#### Compatibility Matrix
| Component | Minimum Version | Recommended |
| ----------------------- | --------------- | ----------- |
| opentelemetry-sdk | 1.39.0 | 1.39+ |
| opentelemetry-api | 1.39.0 | 1.39+ |
| GenAI semantic conv. | 1.36 (baseline) | 1.37+ |
| Python (samples) | 3.12 | 3.13+ |
### The Agent Timeline
The core idea of agent observability is that **a GenAI span is not just an LLM
call**. When an agent decides to call a tool, that tool might query a database,
hit a third-party API, or enqueue a background job. All of that work is part of
the same agent run and belongs in the same trace.
Three attributes bind an agent run together. Set them on **every** span the run
produces:
| Attribute | Purpose |
| ----------------------- | --------------------------------------------- |
| `gen_ai.conversation.id`| Shared ID linking all operations in a run |
| `gen_ai.agent.name` | Which agent produced the span |
| `gen_ai.operation.name` | What kind of operation the span represents |
With those in place, a single agent run reads as one timeline:
```text showLineNumbers title="Agent timeline for one conversation"
POST /chat 6.1s [auto: HTTP]
└─ invoke_agent orchestrator 6.0s [agent]
├─ chat claude-sonnet-4 1.2s [LLM: plan]
├─ execute_tool search_orders 0.9s [tool]
│ └─ db.query SELECT orders 18ms [auto: DB]
├─ invoke_agent billing 2.4s [handoff]
│ ├─ chat claude-sonnet-4 1.1s [LLM]
│ └─ execute_tool issue_refund 1.2s [tool]
│ └─ HTTP POST api.stripe.com 1.1s [auto: httpx]
└─ chat claude-sonnet-4 1.3s [LLM: final answer]
```
Every span above carries the same `gen_ai.conversation.id`. The
auto-instrumented `db.query` and `HTTP POST` spans join the trace because they
run inside the tool-call span's context - so a slow refund shows up as a slow
Stripe call under a specific tool under a specific agent, not as an
unattributed blip somewhere in the system.
### Agent Operation Types
The GenAI semantic conventions define `gen_ai.operation.name` values that
classify each span. Use them consistently - backends and dashboards group and
filter on this attribute.
| `gen_ai.operation.name` | Span represents |
| ----------------------- | ------------------------------------------------ |
| `create_agent` | Creating/registering an agent (e.g. Assistants) |
| `invoke_agent` | One agent taking a turn or being handed control |
| `chat` | An LLM chat completion |
| `execute_tool` | A tool or function call made by an agent |
| `embeddings` | Generating vector embeddings |
| `generate_content` | A multimodal content-generation call |
Most agent frameworks only need `invoke_agent`, `chat`, and `execute_tool`.
Use `create_agent` for remote agent services (OpenAI Assistants, AWS Bedrock
Agents) where agent creation is a distinct, observable step.
### Instrumenting the Agent Layer
Framework auto-instrumentation (LangChain callbacks, provider instrumentors)
usually owns the `chat` spans. You own the **agent-layer** and
**conversation-layer** attributes. A good division of labor:
> Let the framework instrumentation own the LLM-layer spans, and you own the
> agent-layer and conversation-layer attributes.
#### The invoke_agent Span
Wrap each agent turn in an `invoke_agent` span that carries the three binding
attributes. This is the parent under which the agent's LLM and tool spans nest:
```python showLineNumbers title="agent.py - invoke_agent span"
from opentelemetry import trace
tracer = trace.get_tracer("gen_ai.agent")
async def run_agent(agent_name, state, conversation_id):
"""Run one agent turn under an invoke_agent span."""
with tracer.start_as_current_span(
f"invoke_agent {agent_name}"
) as span:
# The three binding attributes
span.set_attribute(
"gen_ai.operation.name", "invoke_agent"
)
span.set_attribute("gen_ai.agent.name", agent_name)
span.set_attribute(
"gen_ai.conversation.id", conversation_id
)
# Optional agent metadata
span.set_attribute("gen_ai.agent.id", state.agent_id)
result = await agent_logic(state)
span.set_attribute(
"gen_ai.response.finish_reasons",
[result.finish_reason],
)
return result
```
#### Threading the Conversation ID
The conversation ID is only useful if it reaches **every** span, including
downstream HTTP clients, database queries, and queue workers. Because those
spans are auto-instrumented and created deep in library code, you cannot set
the attribute on them directly. Two portable options:
1. **Set it at the root and read it back.** Store the conversation ID on the
first span of the run, and have your agent code re-read it and set it on each
child span it creates.
2. **Propagate it as baggage.** OpenTelemetry
[baggage](https://opentelemetry.io/docs/concepts/signals/baggage/) travels
with the context across async boundaries and process hops. A span processor
can then copy it onto every span, including auto-instrumented ones.
The baggage approach keeps the conversation ID flowing without threading it
through every function signature:
```python showLineNumbers title="conversation.py - baggage + span processor"
from opentelemetry import baggage, context
from opentelemetry.sdk.trace import SpanProcessor
def set_conversation(conversation_id):
"""Attach the conversation ID to the current context."""
return context.attach(
baggage.set_baggage(
"gen_ai.conversation.id", conversation_id
)
)
class ConversationSpanProcessor(SpanProcessor):
"""Copy the conversation ID from baggage onto every span."""
def on_start(self, span, parent_context=None):
conversation_id = baggage.get_baggage(
"gen_ai.conversation.id", parent_context
)
if conversation_id:
span.set_attribute(
"gen_ai.conversation.id", conversation_id
)
```
Register `ConversationSpanProcessor` on the tracer provider alongside your
`BatchSpanProcessor`. Now the auto-instrumented `db.query` and `HTTP POST`
spans carry the conversation ID too, and the whole run stays linked.
> **Note**: Baggage is propagated over the wire via the `baggage` HTTP header.
> Do not put secrets or PII in baggage - treat the conversation ID as an opaque
> correlation key.
### Tool Call Observability
Tool calls are **where most agentic failures live**. A model that picks the
wrong tool, passes malformed arguments, or mishandles a tool error will fail
even when every `chat` span looks healthy. Instrument tool calls as
first-class `execute_tool` spans.
#### The execute_tool Span
```python showLineNumbers title="tools.py - execute_tool span"
import json
from opentelemetry import trace
tracer = trace.get_tracer("gen_ai.tool")
async def call_tool(tool, arguments, call_id):
"""Run a tool under an execute_tool span."""
with tracer.start_as_current_span(
f"execute_tool {tool.name}"
) as span:
span.set_attribute(
"gen_ai.operation.name", "execute_tool"
)
span.set_attribute("gen_ai.tool.name", tool.name)
span.set_attribute("gen_ai.tool.call.id", call_id)
span.set_attribute(
"gen_ai.tool.description", tool.description
)
# Arguments and result capture PII - see PII section
span.set_attribute(
"gen_ai.tool.call.arguments",
json.dumps(arguments)[:2000],
)
try:
result = await tool.run(**arguments)
except Exception as e:
span.record_exception(e)
span.set_attribute("error.type", type(e).__name__)
raise
span.set_attribute(
"gen_ai.tool.call.result",
json.dumps(result)[:2000],
)
return result
```
#### Tool Call Attribute Reference
| Attribute | Type | Required | Description |
| ----------------------------- | ------ | ----------- | ------------------------- |
| `gen_ai.operation.name` | string | Yes | Always `"execute_tool"` |
| `gen_ai.tool.name` | string | Recommended | Tool/function name |
| `gen_ai.tool.call.id` | string | Recommended | Unique ID for this call |
| `gen_ai.tool.description` | string | Recommended | What the tool does |
| `gen_ai.tool.type` | string | Recommended | `function`, `extension` |
| `gen_ai.tool.call.arguments` | string | Opt-in | JSON arguments (PII risk) |
| `gen_ai.tool.call.result` | string | Opt-in | JSON result (PII risk) |
| `error.type` | string | Conditional | Exception class on failure|
Setting `error.type` and recording the exception is what lets base14 Scout
highlight the failing tool in the timeline and lets you query for the tools
that fail most often.
#### MCP Tools
The [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is a
common way for agents to reach external tools. An MCP `tools/call` is just a
tool invocation, so model it as an `execute_tool` span with the same
`gen_ai.tool.*` attributes, adding MCP-specific context:
```python showLineNumbers title="mcp_tools.py - MCP call as execute_tool"
with tracer.start_as_current_span(
f"execute_tool {tool_name}"
) as span:
span.set_attribute("gen_ai.operation.name", "execute_tool")
span.set_attribute("gen_ai.tool.name", tool_name)
span.set_attribute("gen_ai.tool.type", "function")
# MCP context
span.set_attribute("mcp.server.name", server_name)
span.set_attribute("mcp.method.name", "tools/call")
result = await mcp_session.call_tool(tool_name, arguments)
```
Because the MCP client's transport is auto-instrumented, the underlying
HTTP or stdio call nests under this span automatically - so a slow or failing
MCP server is attributed to the exact tool the agent invoked. If you run your
own MCP server with Scout, see the
[Scout MCP guides](../../scout-mcp/setup.md) for setup and usage.
### Multi-Agent Orchestration
In a multi-agent system, one agent hands control to another: an orchestrator
calls a specialist, a planner delegates to a worker. Two rules keep these
handoffs readable in the timeline.
#### Rule 1: The Caller Emits the Handoff Span
The **calling** agent emits the `invoke_agent` span for the agent it invokes -
not the agent being called. This makes orchestration explicit: the span sits
under the orchestrator, so the timeline shows who delegated to whom.
```python showLineNumbers title="orchestrator.py - handoff ownership"
async def orchestrator(state, conversation_id):
with tracer.start_as_current_span(
"invoke_agent orchestrator"
) as span:
span.set_attribute(
"gen_ai.operation.name", "invoke_agent"
)
span.set_attribute(
"gen_ai.agent.name", "orchestrator"
)
span.set_attribute(
"gen_ai.conversation.id", conversation_id
)
# The orchestrator emits the handoff span for "billing"
with tracer.start_as_current_span(
"invoke_agent billing"
) as handoff:
handoff.set_attribute(
"gen_ai.operation.name", "invoke_agent"
)
handoff.set_attribute(
"gen_ai.agent.name", "billing"
)
handoff.set_attribute(
"gen_ai.conversation.id", conversation_id
)
return await billing_agent(state)
```
The `billing` agent then emits its own `chat` and `execute_tool` spans under
its own `gen_ai.agent.name`, so its work appears in the billing swim lane.
#### Rule 2: Every Agent Gets a Distinct Name
Give each agent - including sub-agents - a distinct `gen_ai.agent.name`. Spans
that omit it collapse into an `Unknown` swim lane, and you lose the ability to
tell which agent did what or attribute cost per agent. When you fan out to
parallel sub-agents, name each one.
### Agent Metrics
Agent runs need the same GenAI metrics as LLM calls, dimensioned by agent and
tool so you can answer "which agent is slowest?" and "which tool fails most?".
Reuse the metric definitions from the
[LLM Observability guide](./llm-observability.md#token-and-cost-tracking) and
add
agent/tool attributes when recording:
| Metric | Type | Key attributes |
| ---------------------------------- | --------- | ---------------------------------- |
| `gen_ai.client.token.usage` | Histogram | `gen_ai.agent.name`, model |
| `gen_ai.client.operation.duration` | Histogram | `gen_ai.operation.name`, agent |
| `gen_ai.client.cost` | Counter | `gen_ai.agent.name`, model |
| `gen_ai.client.error.count` | Counter | `gen_ai.tool.name`, `error.type` |
Recording `gen_ai.agent.name` on both spans and metrics lets you build
`sum(gen_ai.client.cost) by (gen_ai.agent.name)` to find your most expensive
agent, and `sum(gen_ai.client.error.count) by (gen_ai.tool.name)` to find your
flakiest tool.
### Evaluation and Quality
Agent quality - did it use the right tool, was the final answer correct, did it
hallucinate - is a first-class observability concern. Attach evaluation results
as `gen_ai.evaluation.result` events on the relevant span:
```python showLineNumbers title="eval.py - evaluation event"
span.add_event(
"gen_ai.evaluation.result",
attributes={
"gen_ai.evaluation.name": "tool_selection",
"gen_ai.evaluation.score.value": 1.0,
"gen_ai.evaluation.score.label": "correct",
},
)
```
Record the same score as a `gen_ai.evaluation.score` histogram to track quality
trends over time. See the
[evaluation section of the LLM Observability guide](./llm-observability.md#evaluation-and-quality-metrics)
for the metric definition and dashboard patterns.
### Content Capture, PII and Security
The richest agent-debugging attributes also carry the most sensitive data:
- `gen_ai.input.messages` / `gen_ai.output.messages` - full prompts and
responses
- `gen_ai.system_instructions` - your system prompt
- `gen_ai.tool.call.arguments` / `gen_ai.tool.call.result` - tool inputs and
outputs
These **capture PII and sensitive data by default when enabled**. Protect them:
- **Keep content capture off by default.** Enable it deliberately, and prefer
non-production environments for full capture.
- **Redact at the application layer** before setting attributes, and truncate
long payloads (2000 characters is a practical limit).
- **Redact at the collector** with the
[`redaction`](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/redactionprocessor)
or `attributes` processor as a safety net, so sensitive fields never reach
the backend.
#### Enabling the Latest GenAI Conventions
The GenAI semantic conventions are still under active development. Newer
instrumentations gate the latest attribute shapes behind an environment
variable so existing pipelines do not break:
```bash showLineNumbers title="Terminal"
export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
```
Set this when you want the current message and tool-call attribute formats.
Without it, instrumentations default to the transition baseline. Pin your
collector and backend expectations to the same convention version.
### Production Configuration
Agents send telemetry to a local OpenTelemetry Collector, which authenticates
to base14 Scout and forwards traces, metrics, and logs. Point your application
at the collector with `OTEL_EXPORTER_OTLP_ENDPOINT`, and configure the
collector to export to Scout:
```yaml showLineNumbers title="otel-collector-config.yaml"
extensions:
health_check:
endpoint: 0.0.0.0:13133
zpages:
endpoint: 0.0.0.0:55679
oauth2client:
client_id: ${SCOUT_CLIENT_ID}
client_secret: ${SCOUT_CLIENT_SECRET}
token_url: ${SCOUT_TOKEN_URL}
endpoint_params:
audience: b14collector
timeout: 10s
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
redaction:
allow_all_keys: true
blocked_values:
- "[0-9]{16}" # credit card numbers
- "[\\w.]+@[\\w.]+" # email addresses
batch:
timeout: 10s
send_batch_size: 1024
send_batch_max_size: 2048
exporters:
otlphttp/b14:
endpoint: ${SCOUT_ENDPOINT}
auth:
authenticator: oauth2client
compression: gzip
timeout: 30s
retry_on_failure:
enabled: true
debug:
verbosity: basic
service:
extensions: [health_check, zpages, oauth2client]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, redaction, batch]
exporters: [otlphttp/b14, debug]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlphttp/b14, debug]
logs:
receivers: [otlp]
processors: [memory_limiter, redaction, batch]
exporters: [otlphttp/b14, debug]
```
The `redaction` processor in the traces and logs pipelines strips sensitive
values from captured content before it leaves your environment. For the full
collector reference and deployment options, see
[OpenTelemetry Collector Setup](../../instrument/collector-setup/otel-collector-config.md).
### Troubleshooting
#### Common Issues
##### Issue: Spans show an "Unknown" agent
A span is missing `gen_ai.agent.name`, so the backend cannot assign it to a
swim lane.
**Solutions:**
1. Set `gen_ai.agent.name` on every `invoke_agent`, `chat`, and `execute_tool`
span the agent produces
2. For sub-agents, give each a distinct name - do not reuse the parent's
3. Verify the attribute is set before the span ends, not after
##### Issue: The conversation does not link end to end
LLM and tool spans appear, but downstream HTTP and database spans are not part
of the same conversation.
**Solutions:**
1. Confirm `gen_ai.conversation.id` is set on the root agent span
2. If using baggage, verify `ConversationSpanProcessor` is registered on the
tracer provider and that baggage is attached to the active context
3. Ensure downstream calls run **inside** the agent span's context so they
inherit the trace
##### Issue: Tool spans are missing
Tool calls do not appear as `execute_tool` spans.
**Solutions:**
1. Wrap tool execution explicitly - framework auto-instrumentation covers LLM
calls but often not custom tools
2. Check that the tool wrapper runs inside the `invoke_agent` span's context
3. For MCP tools, ensure the transport (HTTP/stdio) instrumentation is enabled
so the underlying call nests under the `execute_tool` span
### Performance Considerations
Agent instrumentation overhead is negligible next to model and tool latency. A
tool call or LLM completion takes hundreds of milliseconds to seconds; span
creation adds microseconds.
- **Span creation**: ~1-5 microseconds per span
- **Attribute setting**: ~0.5 microseconds per attribute
- **Baggage propagation**: negligible; a single small header
- **Content capture**: the main cost - large argument/result payloads inflate
span size, so truncate and gate capture behind a flag
### FAQ
#### What is the difference between LLM observability and agent observability?
LLM observability traces individual model calls - model, tokens, cost, latency.
Agent observability traces the **whole run**: the loop of LLM calls, tool
calls, and sub-agent handoffs, plus the downstream work each triggers, all
bound by a conversation ID. Agent failures usually live in the tool calls and
handoffs, not the model call - which is exactly what the agent layer makes
visible.
#### Do I need an agent framework to get agent observability?
No. The conventions are framework-agnostic. Whether you use LangGraph, another
framework, or hand-rolled orchestration, you emit `invoke_agent` and
`execute_tool` spans with the GenAI attributes. Frameworks with OpenTelemetry
support give you some spans for free; you still own the conversation ID and
agent names.
#### How do I trace multi-agent handoffs?
The calling agent emits the `invoke_agent` span for the agent it delegates to,
and every agent uses a distinct `gen_ai.agent.name`. This produces one swim
lane per agent and makes the delegation structure explicit in the timeline.
#### Are MCP tool calls traced?
Yes. Model an MCP `tools/call` as an `execute_tool` span with the
`gen_ai.tool.*` attributes plus MCP context (`mcp.server.name`,
`mcp.method.name`). The auto-instrumented transport nests the underlying call
under that span.
#### Can I see the arguments an agent passed to a tool?
Yes, via `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result`. These
capture PII by default, so redact and truncate them, keep capture off in
production unless needed, and add a collector redaction processor as a backstop.
#### How do I find the most expensive or slowest agent?
Record `gen_ai.agent.name` on both spans and metrics, then query
`sum(gen_ai.client.cost) by (gen_ai.agent.name)` for cost and
`histogram_quantile` over `gen_ai.client.operation.duration` grouped by agent
for latency.
### What's Next?
#### Language-Specific Guides
- [LLM Observability (Python)](./llm-observability.md) - full Python setup with
FastAPI, LangGraph, and multi-provider LLMs
- [Rust LLM Observability](./rust-llm-observability.md) - manual GenAI
instrumentation in Rust
- [Java AI Observability](./spring-ai-llm-observability.md) - Spring AI with the
three-layer instrumentation model
#### Framework Guides
- [LangGraph Instrumentation](../../instrument/apps/auto-instrumentation/langgraph.md)
\- node wrapping, conditional routing, tool-calling nodes
- [LlamaIndex Instrumentation](../../instrument/apps/auto-instrumentation/llamaindex.md)
\- RAG, structured output, self-correction loops
- [Vercel AI SDK Instrumentation](../../instrument/apps/auto-instrumentation/vercel-ai-sdk.md)
\- TypeScript multi-stage agent pipelines
#### Scout Platform Features
- [Monitor AI agents in Scout](https://base14.io/scout/llm-observability) -
track token usage, tool failures, and agent latency
- [Creating Alerts](../creating-alerts-with-logx.md) - alert on tool error
rates, cost spikes, or evaluation regressions
- [Create Your First Dashboard](../create-your-first-dashboard.md) - build agent
cost and reliability dashboards
### References
- [Instrumenting AI Agents with OpenTelemetry (Honeycomb)](https://www.honeycomb.io/blog/instrumenting-ai-agents-agent-timeline-opentelemetry-guide)
- [Inside the LLM Call: GenAI Observability with OpenTelemetry](https://opentelemetry.io/blog/2026/genai-observability/)
- [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/)
- [OpenTelemetry GenAI Agent Spans](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/)
- [Model Context Protocol](https://modelcontextprotocol.io/)
### Related Guides
- [LLM Observability][llm-o11y] - the Python end-to-end foundation this guide
builds on
- [LangGraph Instrumentation][langgraph] - framework-specific agent tracing
- [Scout MCP Setup][scout-mcp] - run and observe an MCP server with Scout
- [OpenTelemetry Collector Setup][collector] - full collector configuration
- [Scout Exporter][scout-exporter] - configure base14 Scout authentication
[llm-o11y]: ./llm-observability.md
[langgraph]: ../../instrument/apps/auto-instrumentation/langgraph.md
[scout-mcp]: ../../scout-mcp/setup.md
[collector]: ../../instrument/collector-setup/otel-collector-config.md
[scout-exporter]: ../../instrument/collector-setup/scout-exporter.md
---
## AI Observability Overview - LLM and Agent Instrumentation with OpenTelemetry
## AI Observability
Instrument AI and LLM applications with OpenTelemetry to get **unified traces**
that connect HTTP requests, agent orchestration, LLM API calls, and database
queries in a single view.
### The Problem
Traditional APM tools (Datadog, New Relic) capture HTTP and database telemetry.
Specialized AI tools (LangSmith, Weights & Biases) capture LLM traces. Neither
shows the full picture:
| Tool Type | Captures | Misses |
| ----------------- | ---------------------------------- | ---------------------------------------- |
| Traditional APM | HTTP requests, DB queries, latency | Model name, tokens, cost, prompt content |
| AI-specific tools | LLM calls, prompts, model metadata | HTTP context, DB queries, infrastructure |
| **OpenTelemetry** | **All of the above in one trace** | - |
With OpenTelemetry, a single trace shows that a slow HTTP response was caused by
a specific LLM call in a specific agent, which also triggered 3 database queries
and a fallback to a different provider.
### When to Use AI Observability
| Use Case | Recommendation |
| ------------------------------------------------ | ----------------------------------------------------------------------- |
| Track LLM token usage and costs | AI Observability |
| Monitor agent pipeline performance | AI Observability |
| Evaluate LLM output quality over time | AI Observability |
| Debug slow AI requests end-to-end | AI Observability |
| Attribute costs to agents or business operations | AI Observability |
| Standard HTTP/database monitoring only | [Auto-instrumentation](../../instrument/apps/auto-instrumentation/) |
| Generic custom spans and metrics | [Custom instrumentation](../../instrument/apps/custom-instrumentation/) |
### Guides
| Guide | What It Covers |
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [AI Agent Observability](./agent-observability) | Framework-agnostic concepts and patterns: the agent timeline, GenAI operation types, conversation-id propagation, tool-call (`execute_tool`) instrumentation, MCP tools, multi-agent handoffs, agent metrics and evaluation |
| [LLM Observability](./llm-observability) | End-to-end guide (Python): GenAI semantic conventions, token/cost metrics, agent pipeline spans, evaluation tracking, PII scrubbing, production deployment |
| [Rust LLM Observability](./rust-llm-observability) | End-to-end guide (Rust): GenAI semantic conventions, multi-provider LLM with fallback, token/cost metrics, multi-stage pipeline spans, retry observability, Docker deployment |
| [Spring AI LLM Observability](./spring-ai-llm-observability) | End-to-end guide (Java): Three-layer instrumentation (Java Agent + Spring AI + manual OTel), GenAI semantic conventions, tool calling, RAG, domain metrics, Docker deployment |
| [LangGraph Instrumentation](../../instrument/apps/auto-instrumentation/langgraph) | Framework-specific: LangGraph node wrapping, conditional edge routing, tool-calling nodes, state management, pipeline traces |
| [LlamaIndex Instrumentation](../../instrument/apps/auto-instrumentation/llamaindex) | Framework-specific: LlamaIndex structured output, self-correction loops, multi-provider LLM factory, YAML prompt management |
| [Vercel AI SDK Instrumentation](../../instrument/apps/auto-instrumentation/vercel-ai-sdk) | Framework-specific: Vercel AI SDK v6 LanguageModelV3Middleware, multi-stage pipeline spans, concurrent stage execution, Bun + Hono + pgvector |
### What Gets Instrumented
AI observability builds on top of auto and custom instrumentation, adding an
LLM-specific layer:
#### Auto-Instrumentation Layer (zero code changes)
- **HTTP requests** via FastAPI/Django/Flask instrumentors (Python), tower-http
TraceLayer (Rust), Java Agent (Spring WebFlux)
- **Database queries** via SQLAlchemy/Django ORM instrumentors (Python), SQLx
tracing (Rust), Java Agent (JDBC/R2DBC)
- **Outbound HTTP** via httpx/requests instrumentors (Python), Java Agent
(captures raw LLM API calls)
- **Log correlation** via logging instrumentor (Python),
OpenTelemetryTracingBridge (Rust), Java Agent (Logback/Log4j)
#### Custom AI Layer (GenAI semantic conventions)
- **LLM spans** with model, provider, token counts, cost
- **Prompt/completion events** with PII scrubbing
- **Agent spans** with pipeline orchestration context
- **Tool-call spans** (`execute_tool`) with tool name, arguments, result, and
error type - where most agentic failures live
- **Multi-agent handoffs** bound by `gen_ai.conversation.id` so a full run
reads as one timeline across agents and downstream services
- **Evaluation events** with quality scores and pass/fail
- **Cost metrics** with attribution by agent and business operation
- **Retry/fallback tracking** with error type classification
#### Example: Unified Trace
```text showLineNumbers title="Single trace spanning all layers"
POST /api/generate 4.2s [auto: HTTP]
├─ db.query SELECT context 15ms [auto: DB]
├─ invoke_agent enrich 1.8s [custom: agent]
│ └─ gen_ai.chat claude-sonnet-4 1.7s [custom: LLM]
│ └─ HTTP POST api.anthropic.com 1.7s [auto: httpx]
├─ invoke_agent draft 2.3s [custom: agent]
│ └─ gen_ai.chat claude-sonnet-4 2.2s [custom: LLM]
│ └─ HTTP POST api.anthropic.com 2.2s [auto: httpx]
└─ db.query INSERT result 5ms [auto: DB]
```
### Key Concepts
#### GenAI Semantic Conventions
OpenTelemetry defines
[GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/)
for standardized LLM telemetry. Key attributes:
| Attribute | Example | Purpose |
| ---------------------------- | ------------------- | --------------------- |
| `gen_ai.operation.name` | `"invoke_agent"` | Operation type |
| `gen_ai.provider.name` | `"anthropic"` | LLM provider |
| `gen_ai.request.model` | `"claude-sonnet-4"` | Model used |
| `gen_ai.usage.input_tokens` | `1240` | Tokens consumed |
| `gen_ai.usage.output_tokens` | `320` | Tokens generated |
| `gen_ai.conversation.id` | `"conv-8f2a"` | Binds a full agent run|
| `gen_ai.agent.name` | `"draft"` | Agent in pipeline |
| `gen_ai.tool.name` | `"search_orders"` | Tool an agent invoked |
For the agent-specific attributes (`gen_ai.conversation.id`,
`gen_ai.tool.*`, multi-agent handoffs), see the
[AI Agent Observability guide](./agent-observability).
#### GenAI Metrics
Custom metrics for dashboards and alerting:
| Metric | Type | Purpose |
| ---------------------------------- | --------- | -------------------------------- |
| `gen_ai.client.token.usage` | Histogram | Token consumption by model/agent |
| `gen_ai.client.operation.duration` | Histogram | LLM call latency |
| `gen_ai.client.cost` | Counter | Cost in USD by model/agent |
| `gen_ai.evaluation.score` | Histogram | Output quality scores |
| `gen_ai.client.error.count` | Counter | Errors by provider/type |
### Next Steps
1. **Follow the [LLM Observability guide](./llm-observability)** for a complete
Python setup walkthrough
2. **Follow the [Rust LLM Observability guide](./rust-llm-observability)** for
Rust AI applications with manual GenAI instrumentation
3. **Follow the [Spring AI LLM Observability guide](./spring-ai-llm-observability)**
for Java Spring AI applications with three-layer instrumentation
4. **Set up
[auto-instrumentation](../../instrument/apps/auto-instrumentation/)** for
your web framework if you haven't already
5. **Configure the
[OpenTelemetry Collector](../../instrument/collector-setup/docker-compose-example.md)**
to export telemetry to base14 Scout
---
## LLM Observability with OpenTelemetry - Unified AI Application Tracing Guide
## LLM Observability
Implement unified observability for AI and LLM applications using OpenTelemetry.
This guide shows you how to trace every layer of an AI application - from HTTP
requests through agent orchestration to LLM API calls and database queries - in
a single correlated trace using the OpenTelemetry Python SDK and base14 Scout.
AI applications introduce observability challenges that traditional APM tools
were not designed for. An LLM call is not just an HTTP request - it carries
semantic meaning: which model was used, how many tokens were consumed, what it
cost, whether the output passed quality evaluation. Tools like LangSmith or
Weights & Biases capture LLM-specific telemetry but operate in isolation,
creating blind spots between your application layer (HTTP, database) and your AI
layer (prompts, models, agents). OpenTelemetry bridges this gap with GenAI
semantic conventions that let you capture LLM-specific context alongside
standard application telemetry.
Whether you are building AI agents with LangGraph, LangChain, or custom
orchestration, instrumenting LLM calls from Anthropic, OpenAI, or Google, or
trying to understand why your AI pipeline is slow and expensive, this guide
provides production-ready patterns for unified AI observability. You will learn
how to combine auto-instrumentation for HTTP and database layers with custom
instrumentation for LLM calls, token tracking, cost attribution, and quality
evaluation - all visible in a single trace on base14 Scout.

:::tip TL;DR
Use OpenTelemetry GenAI semantic conventions to trace LLM calls with model,
token, and cost attributes alongside auto-instrumented HTTP and database spans.
This gives you a single trace from HTTP request through agent orchestration to
LLM completion, with token tracking and cost attribution per provider and model.
:::
:::note Running this in production
Storing and querying these traces at production volume is what base14 Scout
does.
[Check out Scout LLM Observability](https://base14.io/scout/llm-observability).
:::
### LLM Observability Overview
This guide demonstrates how to:
- Set up unified OpenTelemetry for an AI application (traces + metrics)
- Use auto-instrumentation for HTTP, database, and external API layers
- Create custom LLM spans following OpenTelemetry GenAI semantic conventions
- Track token usage and calculate cost per LLM call
- Attribute costs to specific agents and business operations
- Instrument agent pipelines (LangGraph or custom) with parent-child spans
- Record evaluation metrics for LLM output quality tracking
- Scrub PII from prompts and completions before recording in telemetry
- Deploy with Docker Compose and the OpenTelemetry Collector
- Export traces and metrics to base14 Scout
### Who This Guide Is For
This documentation is designed for:
- **AI/ML engineers**: building LLM-powered features and needing visibility into
model performance, cost, and quality
- **Backend developers**: adding AI capabilities (chat, agents, RAG) to existing
applications and wanting unified tracing
- **Platform teams**: standardizing observability across AI services and
traditional microservices
- **Engineering teams**: migrating from LangSmith, Weights & Biases, or Helicone
to open-standard observability with OpenTelemetry
- **DevOps engineers**: deploying AI applications with production monitoring,
cost alerting, and quality tracking
### Prerequisites
Before starting, ensure you have:
- **Python 3.12 or later** installed (3.13+ recommended)
- **An LLM API key** from at least one provider (Anthropic, OpenAI, or Google)
- **Scout Collector** configured and accessible from your application
- See
[Docker Compose Setup](../../instrument/collector-setup/docker-compose-example.md)
for local development
- See
[Kubernetes Helm Setup](../../instrument/collector-setup/kubernetes-helm-setup.md)
for production deployment
- Basic understanding of OpenTelemetry concepts (traces, spans, metrics)
#### Compatibility Matrix
| Component | Minimum Version | Recommended |
| ----------------- | --------------- | ----------- |
| Python | 3.12 | 3.13+ |
| opentelemetry-sdk | 1.39.0 | 1.39+ |
| opentelemetry-api | 1.39.0 | 1.39+ |
| FastAPI | 0.115+ | 0.128+ |
| SQLAlchemy | 2.0 | 2.0.45+ |
| LangGraph | 0.2+ | 1.0.6+ |
| Anthropic SDK | 0.40+ | 0.76+ |
| OpenAI SDK | 1.0+ | 1.60+ |
| Google GenAI SDK | 1.0+ | 1.59+ |
### The Unified Trace
The core value of OpenTelemetry for AI applications is the **unified trace** - a
single trace ID that connects every layer of a request, from HTTP entry to LLM
completion and back.
Here is what a trace looks like for an AI pipeline request:
```text showLineNumbers title="Unified trace for POST /campaigns/{id}/run"
POST /campaigns/{id}/run 8.4s [auto: FastAPI]
├─ db.query SELECT connections 12ms [auto: SQLAlchemy]
├─ pipeline.run 8.3s [custom: pipeline]
│ ├─ invoke_agent research 80ms [custom: agent]
│ │ └─ db.query SELECT ... tsvector 45ms [auto: SQLAlchemy]
│ ├─ invoke_agent enrich 2.1s [custom: agent]
│ │ └─ gen_ai.chat claude-sonnet-4 2.0s [custom: LLM]
│ │ └─ HTTP POST api.anthropic.com 1.9s [auto: httpx]
│ ├─ invoke_agent score 1.8s [custom: agent]
│ │ └─ gen_ai.chat claude-sonnet-4 1.7s [custom: LLM]
│ │ └─ HTTP POST api.anthropic.com 1.7s [auto: httpx]
│ ├─ invoke_agent draft 3.2s [custom: agent]
│ │ └─ gen_ai.chat claude-sonnet-4 3.1s [custom: LLM]
│ │ └─ HTTP POST api.anthropic.com 3.1s [auto: httpx]
│ └─ invoke_agent evaluate 1.1s [custom: agent]
│ └─ gen_ai.chat claude-sonnet-4 1.0s [custom: LLM]
│ └─ HTTP POST api.anthropic.com 0.9s [auto: httpx]
└─ db.query INSERT prospects 8ms [auto: SQLAlchemy]
```
Three types of spans work together:
- **Auto-instrumented spans** (no code changes): FastAPI HTTP requests,
SQLAlchemy database queries, httpx outbound HTTP calls
- **Custom LLM spans**: Model name, token counts, cost, prompt/ completion
events following GenAI semantic conventions
- **Custom agent spans**: Pipeline orchestration, agent names, business context
like campaign ID
The auto-instrumented `httpx` span captures the raw HTTP call to
`api.anthropic.com`. The custom `gen_ai.chat` span wraps it, adding LLM-specific
context: which model, how many tokens, what it cost. The custom `invoke_agent`
span wraps both, adding business context: which agent, which campaign. All three
are children of the same trace.
### Installation
Install the core OpenTelemetry packages and auto-instrumentation libraries:
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
```bash showLineNumbers title="Terminal"
pip install \
opentelemetry-api \
opentelemetry-sdk \
opentelemetry-exporter-otlp-proto-http \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-httpx \
opentelemetry-instrumentation-logging
```
```mdx-code-block
```
```bash showLineNumbers title="Terminal"
uv add \
opentelemetry-api \
opentelemetry-sdk \
opentelemetry-exporter-otlp-proto-http \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-httpx \
opentelemetry-instrumentation-logging
```
```mdx-code-block
```
```bash showLineNumbers title="Terminal"
poetry add \
opentelemetry-api \
opentelemetry-sdk \
opentelemetry-exporter-otlp-proto-http \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-httpx \
opentelemetry-instrumentation-logging
```
```mdx-code-block
```
> **Note**: The `httpx` instrumentor is key for AI applications. Most Python LLM
> SDKs (Anthropic, OpenAI) use httpx internally, so this instrumentor
> automatically captures all LLM API calls at the HTTP level without any changes
> to your LLM code.
### Auto-Instrumentation Setup
Auto-instrumentation provides the foundation layer: HTTP spans, database spans,
outbound API call spans, and log correlation. Set this up first - it requires no
changes to your business logic.
#### Telemetry Initialization
```python showLineNumbers title="telemetry.py"
from opentelemetry import metrics, trace
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
OTLPMetricExporter,
)
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
OTLPSpanExporter,
)
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.instrumentation.sqlalchemy import (
SQLAlchemyInstrumentor,
)
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import (
PeriodicExportingMetricReader,
)
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
def setup_telemetry(engine=None):
"""Initialize unified observability for traces and metrics."""
resource = Resource.create(
{
"service.name": "my-ai-service",
"service.version": "1.0.0",
"deployment.environment": "development",
"environment": "development",
}
)
# Traces
trace_provider = TracerProvider(resource=resource)
trace_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(
endpoint="http://localhost:4318/v1/traces"
)
)
)
trace.set_tracer_provider(trace_provider)
# Metrics
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(
endpoint="http://localhost:4318/v1/metrics"
),
export_interval_millis=10000,
)
metric_provider = MeterProvider(
resource=resource, metric_readers=[metric_reader]
)
metrics.set_meter_provider(metric_provider)
# Auto-instrumentation
HTTPXClientInstrumentor().instrument()
LoggingInstrumentor().instrument(set_logging_format=True)
if engine:
SQLAlchemyInstrumentor().instrument(
engine=engine.sync_engine
)
return (
trace.get_tracer("my-ai-service"),
metrics.get_meter("my-ai-service"),
)
```
#### Instrumenting FastAPI
FastAPI instrumentation must be applied after the app is created:
```python showLineNumbers title="main.py"
from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import (
FastAPIInstrumentor,
)
from my_app.telemetry import setup_telemetry
# Initialize telemetry BEFORE creating the app
tracer, meter = setup_telemetry(engine)
app = FastAPI(title="My AI Service")
# Instrument AFTER creation
FastAPIInstrumentor.instrument_app(app)
```
#### What Auto-Instrumentation Captures
| Instrumentor | Captures |
| ------------ | -------------------------------------------- |
| `FastAPI` | HTTP method, path, status code, duration |
| `SQLAlchemy` | SQL statement, parameters, query duration |
| `httpx` | Outbound URL, status, headers, duration |
| `Logging` | Adds `trace_id` and `span_id` to log records |
Auto-instrumentation alone gives you visibility into the application and
infrastructure layers. But an LLM API call appears as a generic `HTTP POST` to
`api.anthropic.com` - you cannot see the model name, token count, or cost.
Custom instrumentation fills this gap.
### Custom LLM Instrumentation
Custom instrumentation adds LLM-specific context to spans using OpenTelemetry
GenAI semantic conventions. This is where AI observability diverges from
standard APM.
#### GenAI Span Attributes
The OpenTelemetry GenAI semantic conventions define standard attributes for LLM
operations. Using them ensures your telemetry works with any
OpenTelemetry-compatible backend.
The following example shows a provider-agnostic `generate` function with full
GenAI span instrumentation. Each LLM provider returns token counts differently —
the tabs below show the provider-specific response handling:
```python showLineNumbers title="llm.py - span setup (common to all providers)"
from opentelemetry import trace
tracer = trace.get_tracer("gen_ai.client")
async def generate(
prompt: str,
system: str,
model: str,
provider: str,
agent_name: str | None = None,
campaign_id: str | None = None,
) -> str:
"""Generate LLM completion with full OTel instrumentation."""
with tracer.start_as_current_span(
f"gen_ai.chat {model}"
) as span:
# Required attributes (GenAI semconv)
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.provider.name", provider)
# Conditionally required
span.set_attribute("gen_ai.request.model", model)
# Recommended
span.set_attribute(
"gen_ai.request.temperature", 0.7
)
span.set_attribute(
"gen_ai.request.max_tokens", 1024
)
span.set_attribute(
"server.address", "api.anthropic.com"
)
# Business context (custom attributes)
if agent_name:
span.set_attribute(
"gen_ai.agent.name", agent_name
)
if campaign_id:
span.set_attribute("campaign_id", campaign_id)
# Call provider (see tabs below for response handling)
response = await call_provider(
provider, model, system, prompt
)
# Record response attributes on span
span.set_attribute(
"gen_ai.response.model", response.model
)
span.set_attribute(
"gen_ai.usage.input_tokens",
response.input_tokens,
)
span.set_attribute(
"gen_ai.usage.output_tokens",
response.output_tokens,
)
return response.content
```
```mdx-code-block
```
```python showLineNumbers title="providers/anthropic.py"
from anthropic import AsyncAnthropic
async def call_anthropic(
model: str, system: str, prompt: str,
temperature: float, max_tokens: int,
) -> LLMResponse:
client = AsyncAnthropic(api_key=api_key)
response = await client.messages.create(
model=model,
max_tokens=max_tokens,
temperature=temperature,
system=system,
messages=[
{"role": "user", "content": prompt}
],
)
content = ""
if response.content:
block = response.content[0]
if hasattr(block, "text"):
content = block.text
return LLMResponse(
content=content,
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
model=response.model,
response_id=response.id,
finish_reason=response.stop_reason,
)
```
```mdx-code-block
```
```python showLineNumbers title="providers/openai.py"
from openai import AsyncOpenAI
async def call_openai(
model: str, system: str, prompt: str,
temperature: float, max_tokens: int,
) -> LLMResponse:
client = AsyncOpenAI(api_key=api_key)
response = await client.chat.completions.create(
model=model,
max_tokens=max_tokens,
temperature=temperature,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": prompt},
],
)
choice = response.choices[0] if response.choices else None
content = (
choice.message.content
if choice and choice.message
else ""
)
usage = response.usage
return LLMResponse(
content=content or "",
input_tokens=usage.prompt_tokens if usage else 0,
output_tokens=(
usage.completion_tokens if usage else 0
),
model=response.model,
response_id=response.id,
finish_reason=(
choice.finish_reason if choice else None
),
)
```
```mdx-code-block
```
```python showLineNumbers title="providers/google.py"
from google import genai
from google.genai.types import GenerateContentConfig
async def call_google(
model: str, system: str, prompt: str,
temperature: float, max_tokens: int,
) -> LLMResponse:
client = genai.Client(api_key=api_key)
config = GenerateContentConfig(
system_instruction=system,
temperature=temperature,
max_output_tokens=max_tokens,
)
response = await client.aio.models.generate_content(
model=model,
contents=prompt,
config=config,
)
content = response.text or ""
usage = response.usage_metadata
input_tokens = (
usage.prompt_token_count
if usage and usage.prompt_token_count
else 0
)
output_tokens = (
usage.candidates_token_count
if usage and usage.candidates_token_count
else 0
)
return LLMResponse(
content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
model=model,
response_id=None,
finish_reason=None,
)
```
```mdx-code-block
```
#### GenAI Span Attribute Reference
| Attribute | Type | Required | Description |
| -------------------------------- | -------- | ----------- | ----------------------------- |
| `gen_ai.operation.name` | string | Yes | Operation type: `"chat"` |
| `gen_ai.provider.name` | string | Yes | Provider: `"anthropic"`, etc. |
| `gen_ai.request.model` | string | Conditional | Model requested |
| `gen_ai.response.model` | string | Recommended | Model actually used |
| `gen_ai.usage.input_tokens` | int | Recommended | Input tokens consumed |
| `gen_ai.usage.output_tokens` | int | Recommended | Output tokens generated |
| `gen_ai.request.temperature` | float | Recommended | Sampling temperature |
| `gen_ai.request.max_tokens` | int | Recommended | Max tokens requested |
| `gen_ai.response.id` | string | Recommended | Provider response ID |
| `gen_ai.response.finish_reasons` | string[] | Recommended | Why generation stopped |
| `server.address` | string | Recommended | Provider API host |
#### Prompt and Completion Events
Record prompts and completions as span events for debugging. Always scrub PII
before recording (see [PII and Security](#pii-and-security)):
```python showLineNumbers title="llm.py - recording events"
# Before calling the LLM
span.add_event(
"gen_ai.user.message",
attributes={
"gen_ai.prompt": scrub_prompt(prompt)[:1000],
"gen_ai.system_instructions": scrub_prompt(system)[:500],
},
)
# After receiving the response
span.add_event(
"gen_ai.assistant.message",
attributes={
"gen_ai.completion": scrub_completion(
response.content
)[:2000],
},
)
```
> **Note**: Truncate prompts and completions to keep span sizes reasonable. 1000
> characters for prompts and 2000 for completions is a practical limit.
#### Error Handling
Record exceptions on spans and track error metrics:
```python showLineNumbers title="llm.py - error handling"
try:
response = await provider.generate(
model=model, system=system, prompt=prompt,
temperature=temperature, max_tokens=max_tokens,
)
except Exception as e:
span.record_exception(e)
span.set_attribute(
"error.type", type(e).__name__
)
error_counter.add(
1,
{
"gen_ai.provider.name": provider_name,
"gen_ai.request.model": model,
"error.type": type(e).__name__,
},
)
raise
```
### Token and Cost Tracking
Token usage and cost are the most critical metrics for AI applications.
Auto-instrumentation cannot capture these - the information is inside the LLM
SDK response, not in HTTP headers.
#### Defining GenAI Metrics
```python showLineNumbers title="llm.py - metric definitions"
from opentelemetry import metrics
meter = metrics.get_meter("gen_ai.client")
token_usage = meter.create_histogram(
name="gen_ai.client.token.usage",
description="Tokens used per LLM call",
unit="{token}",
)
operation_duration = meter.create_histogram(
name="gen_ai.client.operation.duration",
description="Duration of GenAI operations",
unit="s",
)
cost_counter = meter.create_counter(
name="gen_ai.client.cost",
description="Cost of GenAI operations in USD",
unit="usd",
)
```
#### Recording Token Usage
Record input and output tokens separately for analysis:
```python showLineNumbers title="llm.py - recording tokens"
base_attrs = {
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": provider,
"gen_ai.request.model": model,
"gen_ai.response.model": response.model,
"server.address": server_address,
}
# Separate input/output for per-direction analysis
token_usage.record(
response.input_tokens,
{**base_attrs, "gen_ai.token.type": "input"},
)
token_usage.record(
response.output_tokens,
{**base_attrs, "gen_ai.token.type": "output"},
)
operation_duration.record(duration_seconds, base_attrs)
```
#### Cost Calculation and Attribution
Define pricing per model and record costs with business context for attribution:
```mdx-code-block
```
```python showLineNumbers title="pricing.py - Anthropic models"
MODEL_PRICING = {
"claude-opus-4-20250514": {
"input": 15.0,
"output": 75.0,
},
"claude-sonnet-4-20250514": {
"input": 3.0,
"output": 15.0,
},
"claude-haiku-3-5-20241022": {
"input": 0.80,
"output": 4.0,
},
}
```
```mdx-code-block
```
```python showLineNumbers title="pricing.py - OpenAI models"
MODEL_PRICING = {
"gpt-4o": {"input": 2.50, "output": 10.0},
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
"o1": {"input": 15.0, "output": 60.0},
"o1-mini": {"input": 1.10, "output": 4.40},
}
```
```mdx-code-block
```
```python showLineNumbers title="pricing.py - Google models"
MODEL_PRICING = {
"gemini-3-flash": {"input": 0.50, "output": 3.0},
"gemini-3-pro-preview": {
"input": 2.0,
"output": 12.0,
},
"gemini-2.5-pro": {
"input": 1.25,
"output": 10.0,
},
"gemini-2.5-flash": {
"input": 0.30,
"output": 2.50,
},
}
```
```mdx-code-block
```
All pricing is per million tokens. The cost calculation and metric recording is
the same regardless of provider:
```python showLineNumbers title="llm.py - cost calculation"
def calculate_cost(
model: str, input_tokens: int, output_tokens: int
) -> float:
"""Calculate cost in USD for a model call."""
pricing = MODEL_PRICING.get(
model, {"input": 0.0, "output": 0.0}
)
return (
input_tokens * pricing["input"]
+ output_tokens * pricing["output"]
) / 1_000_000
# Record cost with business context
cost = calculate_cost(
model, response.input_tokens, response.output_tokens
)
cost_attrs = {**base_attrs}
if agent_name:
cost_attrs["gen_ai.agent.name"] = agent_name
if campaign_id:
cost_attrs["campaign_id"] = campaign_id
cost_counter.add(cost, cost_attrs)
# Also record on span for per-request visibility
span.set_attribute("gen_ai.usage.cost_usd", cost)
```
This enables queries like:
```text showLineNumbers title="Example queries in base14 Scout"
# Cost by agent
sum(gen_ai.client.cost) by (gen_ai.agent.name)
# Token usage by model
sum(gen_ai.client.token.usage) by (gen_ai.request.model)
# Cost per campaign
sum(gen_ai.client.cost) by (campaign_id)
```
### Agent Pipeline Observability
Agent orchestration frameworks like LangGraph do not have OpenTelemetry
auto-instrumentation. Custom spans are required to track which agent is
executing, how long each step takes, and where errors occur.
#### Wrapping Agent Nodes
Create a wrapper function that adds an OTel span around each agent in your
pipeline:
```python showLineNumbers title="graph.py"
from opentelemetry import trace
tracer = trace.get_tracer("gen_ai.agent")
def wrap_agent(name, agent_fn, needs_session=False):
"""Wrap an agent function with OTel agent span."""
async def wrapped(state):
with tracer.start_as_current_span(
f"invoke_agent {name}"
) as span:
# Required attributes (GenAI agent semconv)
span.set_attribute(
"gen_ai.operation.name", "invoke_agent"
)
span.set_attribute("gen_ai.agent.name", name)
# Business context
span.set_attribute(
"campaign_id", state.campaign_id
)
if needs_session:
result = await agent_fn(state, session)
else:
result = await agent_fn(state)
span.set_attribute(
"errors_count", len(result.errors)
)
return result
return wrapped
```
#### Building the Pipeline
```python showLineNumbers title="graph.py - pipeline construction"
from langgraph.graph import END, START, StateGraph
def create_pipeline(session):
"""Create instrumented LangGraph pipeline."""
graph = StateGraph(AgentState)
graph.add_node(
"research",
wrap_agent(
"research", research_agent,
needs_session=True,
),
)
graph.add_node(
"enrich",
wrap_agent("enrich", enrich_agent),
)
graph.add_node(
"score",
wrap_agent("score", score_agent),
)
graph.add_node(
"draft",
wrap_agent("draft", draft_agent),
)
graph.add_node(
"evaluate",
wrap_agent("evaluate", evaluate_agent),
)
graph.add_edge(START, "research")
graph.add_edge("research", "enrich")
graph.add_edge("enrich", "score")
graph.add_edge("score", "draft")
graph.add_edge("draft", "evaluate")
graph.add_edge("evaluate", END)
return graph.compile()
```
#### Pipeline-Level Span
Wrap the entire pipeline run in a parent span to capture aggregate metrics:
```python showLineNumbers title="graph.py - pipeline run"
async def run_pipeline(
campaign_id, target_keywords, target_titles, session,
score_threshold=50, quality_threshold=60,
):
"""Run pipeline with top-level observability span."""
with tracer.start_as_current_span(
"pipeline.run"
) as span:
span.set_attribute("campaign_id", campaign_id)
span.set_attribute(
"target_keywords", target_keywords
)
initial_state = AgentState(
campaign_id=campaign_id,
target_keywords=target_keywords,
target_titles=target_titles,
score_threshold=score_threshold,
quality_threshold=quality_threshold,
)
pipeline = create_pipeline(session)
result = await pipeline.ainvoke(initial_state)
# Record pipeline outcome on span
span.set_attribute(
"prospects_found", len(result.prospects)
)
span.set_attribute(
"drafts_generated", len(result.drafts)
)
span.set_attribute(
"evaluations_passed",
sum(
1
for e in result.evaluations
if e.passed
),
)
return result
```
### Evaluation and Quality Metrics
LLM output quality is a first-class observability concern. The OpenTelemetry
GenAI semantic conventions define evaluation events and metrics for tracking
quality over time.
#### Recording Evaluation Events
```python showLineNumbers title="agents/evaluate.py"
from opentelemetry import metrics, trace
tracer = trace.get_tracer("gen_ai.evaluation")
meter = metrics.get_meter("gen_ai.evaluation")
evaluation_score = meter.create_histogram(
name="gen_ai.evaluation.score",
description="Quality evaluation scores (0-1 normalized)",
unit="1",
)
async def evaluate_draft(draft, campaign_id, threshold):
"""Evaluate draft quality with OTel events."""
with tracer.start_as_current_span(
"evaluate.draft"
) as span:
span.set_attribute(
"prospect_id", draft.prospect_id
)
score = await run_quality_check(draft)
passed = score >= threshold
span.set_attribute("quality_score", score)
span.set_attribute("passed", passed)
# GenAI evaluation event (semconv)
span.add_event(
"gen_ai.evaluation.result",
attributes={
"gen_ai.evaluation.name": (
"email_quality"
),
"gen_ai.evaluation.score.value": score,
"gen_ai.evaluation.score.label": (
"passed" if passed else "failed"
),
"gen_ai.evaluation.explanation": (
feedback[:200]
),
},
)
# Record metric for dashboards
evaluation_score.record(
score / 100.0,
{
"gen_ai.evaluation.name": (
"email_quality"
),
"gen_ai.evaluation.score.label": (
"passed" if passed else "failed"
),
"campaign_id": campaign_id,
},
)
return EvaluationResult(
quality_score=score,
passed=passed,
feedback=feedback,
)
```
#### GenAI Evaluation Event Attributes
| Attribute | Type | Description |
| ------------------------------- | ------ | ----------------------------------------- |
| `gen_ai.evaluation.name` | string | Evaluation name (e.g., `"email_quality"`) |
| `gen_ai.evaluation.score.value` | number | Raw score value |
| `gen_ai.evaluation.score.label` | string | `"passed"` or `"failed"` |
| `gen_ai.evaluation.explanation` | string | Human-readable feedback |
### PII and Security
LLM prompts and completions often contain personally identifiable information.
Recording raw prompts in telemetry creates a compliance risk. Scrub PII before
adding prompt or completion events to spans.
#### PII Scrubbing
```python showLineNumbers title="pii.py"
import re
from dataclasses import dataclass
@dataclass
class PIIPattern:
name: str
pattern: re.Pattern[str]
replacement: str
DEFAULT_PATTERNS = [
PIIPattern(
"email",
re.compile(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+"
r"\.[A-Z|a-z]{2,}\b"
),
"[EMAIL]",
),
PIIPattern(
"phone",
re.compile(
r"(?:\+?1[-.\s]?)?\(?[0-9]{3}\)?[-.\s]?"
r"[0-9]{3}[-.\s]?[0-9]{4}\b"
),
"[PHONE]",
),
PIIPattern(
"linkedin",
re.compile(
r"https?://(?:www\.)?linkedin\.com"
r"/in/[A-Za-z0-9_-]+/?"
),
"[LINKEDIN_URL]",
),
PIIPattern(
"ssn",
re.compile(r"\b\d{3}[-]?\d{2}[-]?\d{4}\b"),
"[SSN]",
),
]
def scrub_pii(text: str) -> str:
"""Replace PII with safe placeholders."""
result = text
for p in DEFAULT_PATTERNS:
result = p.pattern.sub(p.replacement, result)
return result
```
#### Applying PII Scrubbing to Telemetry
Always scrub before recording span events:
```python showLineNumbers title="llm.py - PII-safe events"
from my_app.pii import scrub_pii
# Record prompt with PII scrubbed and truncated
span.add_event(
"gen_ai.user.message",
attributes={
"gen_ai.prompt": scrub_pii(prompt)[:1000],
"gen_ai.system_instructions": scrub_pii(system)[:500],
},
)
# Record completion with PII scrubbed and truncated
span.add_event(
"gen_ai.assistant.message",
attributes={
"gen_ai.completion": scrub_pii(
response.content
)[:2000],
},
)
```
#### Security Considerations
- **Never record raw prompts** that may contain user data, API keys, or
credentials in span attributes or events
- **Truncate content** to avoid oversized spans (1000 chars for prompts, 2000
for completions)
- **Disable prompt recording** in production if compliance requirements prohibit
it - the GenAI span attributes (model, tokens, cost) still provide full
operational visibility
- **Use the OTel Collector `attributes` processor** to redact sensitive fields
before export if additional filtering is needed
- **GDPR/HIPAA**: If prompts may contain regulated data, consider recording only
token counts and model metadata, not content
### Production Configuration
#### Environment Variables
```bash showLineNumbers title=".env"
# Application
OTEL_SERVICE_NAME=my-ai-service
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_ENABLED=true
SCOUT_ENVIRONMENT=development
# LLM Provider
LLM_PROVIDER=anthropic
LLM_MODEL=claude-sonnet-4-20250514
ANTHROPIC_API_KEY=sk-ant-...
# Fallback (optional)
FALLBACK_PROVIDER=google
FALLBACK_MODEL=gemini-3-flash
GOOGLE_API_KEY=...
```
#### OpenTelemetry Collector Configuration
```yaml showLineNumbers title="otel-collector-config.yaml"
extensions:
health_check:
endpoint: 0.0.0.0:13133
zpages:
endpoint: 0.0.0.0:55679
oauth2client:
client_id: ${SCOUT_CLIENT_ID}
client_secret: ${SCOUT_CLIENT_SECRET}
token_url: ${SCOUT_TOKEN_URL}
endpoint_params:
audience: b14collector
timeout: 10s
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
batch:
timeout: 10s
send_batch_size: 1024
send_batch_max_size: 2048
attributes:
actions:
- key: deployment.environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
- key: environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
exporters:
otlphttp/b14:
endpoint: ${SCOUT_ENDPOINT}
auth:
authenticator: oauth2client
compression: gzip
timeout: 30s
retry_on_failure:
enabled: true
initial_interval: 1s
max_interval: 30s
max_elapsed_time: 300s
debug:
verbosity: basic
sampling_initial: 5
sampling_thereafter: 200
service:
extensions: [health_check, zpages, oauth2client]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlphttp/b14, debug]
metrics:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlphttp/b14, debug]
logs:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlphttp/b14, debug]
```
#### Docker Compose Deployment
```yaml showLineNumbers title="compose.yml"
services:
app:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/mydb
- OTEL_SERVICE_NAME=my-ai-service
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
- OTEL_ENABLED=true
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- SCOUT_ENVIRONMENT=${SCOUT_ENVIRONMENT:-development}
depends_on:
postgres:
condition: service_healthy
otel-collector:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 60s
timeout: 5s
retries: 3
postgres:
image: postgres:18
environment:
POSTGRES_DB: mydb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
otel-collector:
image: otel/opentelemetry-collector-contrib:0.144.0
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
ports:
- "4317:4317"
- "4318:4318"
- "13133:13133"
- "55679:55679"
environment:
- SCOUT_CLIENT_ID=${SCOUT_CLIENT_ID:-}
- SCOUT_CLIENT_SECRET=${SCOUT_CLIENT_SECRET:-}
- SCOUT_TOKEN_URL=${SCOUT_TOKEN_URL:-https://auth.base14.io/oauth/token}
- SCOUT_ENDPOINT=${SCOUT_ENDPOINT:-https://collector.base14.io}
- SCOUT_ENVIRONMENT=${SCOUT_ENVIRONMENT:-development}
```
### Retry and Fallback Observability
LLM APIs are inherently unreliable. Retries and provider fallbacks should be
observable so you can track error rates, retry frequency, and fallback triggers.
#### Retry Metrics
```python showLineNumbers title="llm.py - retry instrumentation"
from opentelemetry import metrics
from tenacity import (
RetryCallState,
retry,
stop_after_attempt,
wait_exponential,
)
meter = metrics.get_meter("gen_ai.client")
retry_counter = meter.create_counter(
name="gen_ai.client.retry.count",
description="Number of retry attempts",
unit="{retry}",
)
fallback_counter = meter.create_counter(
name="gen_ai.client.fallback.count",
description="Number of fallback triggers",
unit="{fallback}",
)
error_counter = meter.create_counter(
name="gen_ai.client.error.count",
description="Number of errors by type",
unit="{error}",
)
def on_retry(retry_state: RetryCallState):
"""Record retry metric before each attempt."""
error_type = "unknown"
if (
retry_state.outcome
and retry_state.outcome.exception()
):
error_type = type(
retry_state.outcome.exception()
).__name__
retry_counter.add(
1,
{
"gen_ai.provider.name": provider_name,
"error.type": error_type,
"retry.attempt": (
retry_state.attempt_number
),
},
)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(
multiplier=1, min=1, max=10
),
before_sleep=on_retry,
)
async def generate(self, model, system, prompt, **kw):
"""LLM call with automatic retry."""
...
```
#### Fallback Instrumentation
When the primary provider fails, record the fallback trigger on both the span
and as a metric:
```python showLineNumbers title="llm.py - fallback tracking"
except Exception as e:
span.record_exception(e)
span.set_attribute("error.type", type(e).__name__)
if use_fallback and provider != fallback_provider:
span.set_attribute(
"gen_ai.fallback.triggered", True
)
fallback_counter.add(
1,
{
"gen_ai.provider.name": provider,
"gen_ai.fallback.provider": (
fallback_provider
),
"error.type": type(e).__name__,
},
)
# Retry with fallback provider
return await self.generate(
prompt=prompt,
system=system,
provider=fallback_provider,
model=fallback_model,
use_fallback=False,
)
raise
```
### Troubleshooting
#### Verify Telemetry Is Working
Check that the OTel Collector is receiving data:
```bash showLineNumbers title="Verify collector health"
# Check collector health
curl http://localhost:13133
# View recent traces in zpages debug UI
# Open http://localhost:55679/debug/tracez in a browser
```
#### Enable Debug Mode
Set debug-level logging to see span exports:
```python showLineNumbers title="Debug logging"
import logging
logging.getLogger("opentelemetry").setLevel(
logging.DEBUG
)
```
#### Common Issues
##### Issue: LLM spans not appearing in traces
The custom `gen_ai.chat` span exists but is not connected to the HTTP request
trace.
**Solutions:**
1. Ensure `setup_telemetry()` is called **before** creating the FastAPI app
2. Verify `HTTPXClientInstrumentor().instrument()` is called during setup - this
creates the parent HTTP span that the custom span nests under
3. Check that the `gen_ai.chat` span is created inside an async context where
the trace context is propagated
##### Issue: Token counts are zero
**Solutions:**
1. Check your LLM SDK version - older versions may not expose `usage` on the
response object
2. Verify the provider response object has `input_tokens` and `output_tokens`
fields (naming varies by provider)
3. For Google GenAI, check `response.usage_metadata` instead of `response.usage`
##### Issue: Cost metrics not accurate
**Solutions:**
1. Verify your `MODEL_PRICING` dictionary contains the exact model ID string
returned by the provider (e.g., `claude-sonnet-4-20250514`, not
`claude-sonnet-4`)
2. Check that cost is calculated with `/1_000_000` (pricing is per million
tokens)
##### Issue: Spans not exported to Scout
**Solutions:**
1. Confirm the OTel Collector is running: `curl http://localhost:13133`
2. Check collector logs: `docker compose logs otel-collector`
3. Verify `OTEL_EXPORTER_OTLP_ENDPOINT` points to the collector, not directly to
Scout
4. Ensure `SCOUT_CLIENT_ID` and `SCOUT_CLIENT_SECRET` are set in the collector
environment
### Performance Considerations
OpenTelemetry overhead is negligible relative to LLM API latency. A typical LLM
call takes 1-5 seconds; span creation and metric recording add microseconds.
#### Impact Factors
- **Span creation**: ~1-5 microseconds per span
- **Attribute setting**: ~0.5 microseconds per attribute
- **Metric recording**: ~1 microsecond per record
- **Batch export**: Happens in background thread, no request impact
#### Optimization Strategies
##### 1. Use BatchSpanProcessor in Production
The `BatchSpanProcessor` batches spans before export, avoiding per-span network
calls:
```python showLineNumbers title="Production trace setup"
trace_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint=endpoint),
max_queue_size=2048,
max_export_batch_size=512,
schedule_delay_millis=5000,
)
)
```
##### 2. Truncate Prompt and Completion Events
Long prompts and completions increase span payload size. Always truncate:
```python showLineNumbers title="Truncation"
span.add_event(
"gen_ai.user.message",
attributes={
"gen_ai.prompt": scrub_pii(prompt)[:1000],
},
)
```
##### 3. Disable Prompt Recording in High-Volume Scenarios
If you process thousands of LLM calls per minute and do not need prompt data in
traces, skip the event recording:
```python showLineNumbers title="Conditional recording"
if settings.record_prompts:
span.add_event(
"gen_ai.user.message",
attributes={
"gen_ai.prompt": scrub_pii(prompt)[:1000],
},
)
```
##### 4. Use the Collector Memory Limiter
The OTel Collector `memory_limiter` processor prevents out-of-memory issues
under heavy load:
```yaml showLineNumbers title="otel-collector-config.yaml"
processors:
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
```
### FAQ
#### Does OpenTelemetry add latency to LLM calls?
No. Span creation takes microseconds. LLM API calls take seconds. The overhead
is unmeasurable in practice. `BatchSpanProcessor` exports spans in a background
thread, so export does not block request handling.
#### How do I track cost across multiple LLM providers?
Use the `gen_ai.client.cost` counter metric with `gen_ai.provider.name` and
`gen_ai.request.model` attributes. Define a pricing dictionary per model and
calculate cost from token counts. This gives you `sum(cost) by (provider)` in
your dashboards.
#### Can I see the actual prompts and completions in traces?
Yes, if you record them as `gen_ai.user.message` and `gen_ai.assistant.message`
span events. Always scrub PII first. You can disable prompt recording in
production for compliance.
#### How does this compare to LangSmith?
LangSmith provides deep LLM-specific tracing but operates in isolation from your
HTTP and database telemetry. OpenTelemetry gives you a single trace that spans
all layers. You can see that a slow HTTP response was caused by a specific LLM
call in a specific agent, and that the same request also ran 3 database queries.
LangSmith cannot show that correlation.
#### Do I need to instrument each LLM provider separately?
No. Use a provider-agnostic abstraction (like the `LLMClient` pattern shown in
this guide) that wraps all providers with the same span structure. The
`gen_ai.provider.name` attribute identifies which provider handled each call.
#### How do I monitor LLM evaluation quality over time?
Record `gen_ai.evaluation.score` as a histogram metric with
`gen_ai.evaluation.name` and `gen_ai.evaluation.score.label` attributes. This
lets you track pass rates, score distributions, and quality trends per
evaluation type in your dashboards.
#### What if my agent framework supports tracing natively?
Some frameworks (e.g., LangChain) have their own tracing. You can still use
OpenTelemetry alongside or instead. The key advantage of OpenTelemetry is
portability - your traces work with any backend (base14 Scout, Jaeger, Grafana
Tempo, etc.) without vendor lock-in.
#### How do I reduce trace volume for high-throughput AI apps?
Use head-based sampling in the OTel Collector or SDK. For AI applications, a
practical approach is to sample 100% of error traces and a percentage of
successful traces. The `probabilistic_sampler` processor in the collector
handles this.
#### Can I track which agent is the most expensive?
Yes. Set `gen_ai.agent.name` as an attribute on both the `gen_ai.chat` span and
the `gen_ai.client.cost` metric. This enables
`sum(gen_ai.client.cost) by (gen_ai.agent.name)` in your dashboards.
#### How do I add observability to an existing AI app?
Start with auto-instrumentation (FastAPI, SQLAlchemy, httpx) - this requires no
code changes. Then add custom LLM spans in your LLM client layer. Finally, add
agent-level spans if you use an orchestration framework. Each layer adds value
independently.
### What's Next?
#### Advanced Topics
- [Python Custom Instrumentation][py-custom] - Manual tracing and metrics for
Python applications
- [FastAPI Auto-Instrumentation][fastapi-auto] - Comprehensive FastAPI
instrumentation guide
#### Scout Platform Features
- [Monitor LLM costs and latency in Scout](https://base14.io/scout/llm-observability)
\- Track token usage, provider costs, and response quality across all
your LLM operations
- [Creating Alerts](../creating-alerts-with-logx.md) - Set up alerts for LLM
error rates, cost spikes, or quality degradation
- [Create Your First Dashboard](../create-your-first-dashboard.md) \- Build
dashboards for token usage, cost attribution, and evaluation scores
#### Deployment and Operations
- [Docker Compose Setup][docker-setup] - Local development with the OTel
Collector
- [Kubernetes Helm Setup][k8s-setup] - Production deployment
- [Scout Exporter][scout-exporter] - Configure authentication with base14 Scout
### Complete Example
The
[AI Sales Intelligence](https://github.com/base14/examples/tree/main/python/ai-sales-intelligence)
example application implements every pattern described in this guide. It is a
FastAPI + LangGraph + multi-provider LLM application with full OpenTelemetry
instrumentation.
#### Project Structure
```text showLineNumbers title="Project structure"
ai-sales-intelligence/
├── src/sales_intelligence/
│ ├── telemetry.py # OTel setup (auto + custom)
│ ├── llm.py # LLM client with GenAI spans
│ ├── graph.py # LangGraph pipeline with agent spans
│ ├── agents/
│ │ ├── research.py # Database search agent
│ │ ├── enrich.py # LLM enrichment agent
│ │ ├── score.py # LLM scoring agent
│ │ ├── draft.py # LLM email draft agent
│ │ └── evaluate.py # LLM quality evaluation agent
│ ├── pii.py # PII scrubbing for telemetry
│ ├── config.py # Provider-agnostic settings
│ ├── main.py # FastAPI application
│ └── middleware/
│ └── metrics.py # HTTP request metrics
├── otel-collector-config.yaml
├── compose.yml
└── pyproject.toml
```
#### Key Files
| File | Demonstrates |
| -------------- | ----------------------------------------------- |
| `telemetry.py` | Auto-instrumentation setup |
| `llm.py` | GenAI spans, token/cost metrics, retry/fallback |
| `graph.py` | Agent pipeline spans with LangGraph |
| `evaluate.py` | Evaluation events and quality metrics |
| `pii.py` | PII scrubbing before telemetry recording |
| `config.py` | Provider-agnostic settings with Pydantic |
| `compose.yml` | Docker deployment with OTel Collector |
### References
- [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/)
- [OpenTelemetry Python SDK](https://opentelemetry.io/docs/languages/python/)
- [OpenTelemetry Collector Configuration](https://opentelemetry.io/docs/collector/configuration/)
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/)
### Related Guides
- [Python Custom Instrumentation][py-custom] - Manual tracing and metrics
fundamentals
- [FastAPI Auto-Instrumentation][fastapi-auto] - Comprehensive FastAPI setup
- [Docker Compose Setup][docker-setup] - Local collector deployment
- [Scout Exporter][scout-exporter] - Configure base14 Scout authentication
[py-custom]: ../../instrument/apps/custom-instrumentation/python.md
[fastapi-auto]: ../../instrument/apps/auto-instrumentation/fast-api.md
[docker-setup]: ../../instrument/collector-setup/docker-compose-example.md
[scout-exporter]: ../../instrument/collector-setup/scout-exporter.md
[k8s-setup]: ../../instrument/collector-setup/kubernetes-helm-setup.md
---
## Rust LLM Observability with OpenTelemetry - AI Application Tracing Guide
## Rust LLM Observability
Implement unified observability for Rust AI and LLM applications using
OpenTelemetry. This guide shows you how to trace every layer of a Rust AI
application - from HTTP requests through pipeline orchestration to LLM API calls
and database queries - in a single correlated trace using the OpenTelemetry Rust
SDK and base14 Scout. You will instrument a multi-stage AI pipeline that
retrieves data from PostgreSQL, analyzes trends with a fast LLM, generates
structured narratives with a capable LLM, and assembles the final output - with
every stage, token count, and cost captured in telemetry.
Rust AI applications introduce observability challenges that generic APM tooling
was not designed for. An LLM call is not just an HTTP request - it carries
semantic meaning: which model was used, how many tokens were consumed, what it
cost, whether the response was a fallback from another provider. Unlike Python
or Node.js, Rust has no auto-instrumentation libraries for LLM SDKs like
`async-openai` or Anthropic HTTP clients. You need manual spans following
OpenTelemetry GenAI semantic conventions, custom metrics for token and cost
tracking, and careful integration with the `tracing` ecosystem that Rust
applications depend on. The payoff is complete visibility: a single trace that
shows exactly which model answered, how long it took, what it cost, and whether
retries or provider fallbacks were involved.
Whether you are building AI pipelines with Axum, integrating OpenAI or Anthropic
via `async-openai` or raw HTTP clients, or running local models through Ollama,
this guide provides production-ready patterns for unified AI observability in
Rust. You will learn how to set up three-pillar telemetry (traces, metrics,
logs), create GenAI spans with the correct semantic conventions, define six
standard LLM metrics, implement retry and fallback observability, and deploy
with Docker Compose and the OpenTelemetry Collector - all visible in a single
trace on base14 Scout.
:::tip TL;DR
Instrument Rust AI applications with OpenTelemetry by creating manual GenAI
spans with `tracing::info_span!`, defining six standard LLM metrics with
`LazyLock`, and bridging `tracing` to the OTLP exporter. This gives you unified
traces from HTTP entry through pipeline stages to individual LLM completions,
with token and cost tracking per provider and model.
:::
> **Note:** For general LLM observability patterns applicable to any language,
> see the [LLM Observability guide](../llm-observability). This guide focuses
> specifically on Rust integration patterns. For basic Axum instrumentation
> without AI, see the
> [Axum guide](../../instrument/apps/auto-instrumentation/axum.md).
:::note Running this in production
Storing and querying these traces at production volume is what base14 Scout
does.
[Check out Scout LLM Observability](https://base14.io/scout/llm-observability).
:::
### Who This Guide Is For
This documentation is designed for:
- **Rust AI developers**: building LLM-powered features with `async-openai`,
Anthropic HTTP clients, or custom providers and needing visibility into model
performance, cost, and pipeline throughput
- **Backend developers**: adding AI capabilities (report generation, analysis,
chat) to existing Axum or Actix-web applications and wanting unified tracing
across all layers
- **Platform teams**: standardizing observability across Rust AI services and
traditional microservices using OpenTelemetry
- **Engineering teams**: migrating from proprietary AI observability tools to
open-standard OpenTelemetry
- **DevOps engineers**: deploying Rust AI applications with production
monitoring, cost alerting, and pipeline health tracking
### Rust LLM Observability Overview
This guide demonstrates how to:
- Set up three-pillar OpenTelemetry for a Rust AI application (traces +
metrics + logs)
- Create custom LLM spans following OpenTelemetry GenAI semantic conventions
- Define GenAI metrics for token usage, cost, duration, errors, retries, and
fallbacks
- Instrument multi-stage AI pipelines with parent-child spans
- Track token usage and calculate cost per LLM call
- Implement multi-provider LLM support with retry and fallback observability
- Correlate trace IDs with database records for end-to-end debugging
- Deploy with Docker Compose and the OpenTelemetry Collector
- Export traces, metrics, and logs to base14 Scout
### Prerequisites
Before starting, ensure you have:
- **Rust 1.85 or later** installed (1.92+ recommended for edition 2024 support)
- **An LLM API key** from at least one provider (OpenAI, Anthropic, or Google)
- **Scout Collector** configured and accessible from your application
- See
[Docker Compose Setup](../../instrument/collector-setup/docker-compose-example.md)
for local development
- See
[Kubernetes Helm Setup](../../instrument/collector-setup/kubernetes-helm-setup.md)
for production deployment
- Basic understanding of OpenTelemetry concepts (traces, spans, metrics)
- Familiarity with the Rust `tracing` crate
#### Compatibility Matrix
| Component | Minimum Version | Recommended |
| --------------------- | --------------- | ----------- |
| Rust | 1.85 | 1.92+ |
| opentelemetry | 0.28 | 0.32+ |
| opentelemetry_sdk | 0.28 | 0.32+ |
| opentelemetry-otlp | 0.28 | 0.32+ |
| tracing-opentelemetry | 0.29 | 0.33+ |
| tracing | 0.1 | 0.1+ |
| tracing-subscriber | 0.3 | 0.3+ |
| async-openai | 0.25+ | 0.33+ |
| Axum | 0.7+ | 0.8+ |
| SQLx | 0.7+ | 0.8+ |
### The Unified Trace
The core value of OpenTelemetry for Rust AI applications is the **unified
trace** - a single trace ID that connects every layer of a request, from HTTP
entry through pipeline stages to LLM completions and back.
Here is what a trace looks like for an AI pipeline request:
```text showLineNumbers title="Unified trace for POST /api/reports"
POST /api/reports 6.8s [HTTP: tower-http]
├─ pipeline report 6.7s [custom: orchestrator]
│ ├─ pipeline_stage retrieve 45ms [custom: pipeline]
│ │ └─ db.query SELECT data_points 12ms [SQLx]
│ ├─ pipeline_stage analyze 2.1s [custom: pipeline]
│ │ └─ gen_ai.chat gpt-4.1-mini 2.0s [custom: LLM]
│ ├─ pipeline_stage generate 4.3s [custom: pipeline]
│ │ └─ gen_ai.chat gpt-4.1 4.2s [custom: LLM]
│ └─ pipeline_stage format 5ms [custom: pipeline]
└─ db.query INSERT reports 8ms [SQLx]
```
Three types of spans work together:
- **HTTP spans** (tower-http `TraceLayer`): Capture request method, path, status
code, and latency automatically
- **Custom LLM spans**: Model name, provider, token counts, cost,
prompt/completion events following GenAI semantic conventions
- **Custom pipeline spans**: Stage names, data point counts, business context
like report ID and trace ID correlation
The HTTP span captures the incoming request. The pipeline span orchestrates
stages. Each `gen_ai.chat` span wraps an LLM call, adding provider, model,
token, and cost context. All are children of the same trace - giving you full
visibility from HTTP entry to LLM completion.
### Installation
Add the OpenTelemetry and tracing dependencies to your `Cargo.toml`:
```toml showLineNumbers title="Cargo.toml"
[dependencies]
# Web Framework
axum = { version = "0.8", features = ["macros"] }
tower = { version = "0.5", features = ["full"] }
tower-http = { version = "0.6", features = ["trace", "cors", "timeout", "request-id"] }
# Async Runtime
tokio = { version = "1", features = ["full", "tracing"] }
# Database
sqlx = { version = "0.8", features = [
"runtime-tokio", "tls-rustls", "postgres",
"macros", "uuid", "chrono", "json"
] }
# LLM Providers
async-openai = { version = "0.33", features = ["chat-completion"] }
reqwest = { version = "0.12", features = ["json"] }
# OpenTelemetry
opentelemetry = "0.32"
opentelemetry_sdk = { version = "0.32", features = [
"rt-tokio", "logs", "metrics"
] }
opentelemetry-otlp = { version = "0.32", features = [
"grpc-tonic", "trace", "logs", "metrics"
] }
opentelemetry-appender-tracing = "0.32"
# Tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = [
"env-filter", "json"
] }
tracing-opentelemetry = "0.33"
# Utilities
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1"
dotenvy = "0.15"
```
> **Note:** The `opentelemetry`, `opentelemetry_sdk`, and `opentelemetry-otlp`
> crates must use the same version. The `tracing-opentelemetry` version must be
> compatible - check the
> [tracing-opentelemetry compatibility matrix](https://github.com/open-telemetry/opentelemetry-rust/tree/main/opentelemetry-tracing)
> for the correct pairing.
### Telemetry Initialization
Initialize the three OpenTelemetry pillars - traces, metrics, and logs - with
OTLP gRPC export. This runs once at application startup.
```rust showLineNumbers title="src/telemetry/init.rs"
use opentelemetry::KeyValue;
use opentelemetry::global;
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::{
Resource,
logs::SdkLoggerProvider,
metrics::{PeriodicReader, SdkMeterProvider},
trace::SdkTracerProvider,
};
use std::time::Duration;
use tracing_opentelemetry::OpenTelemetryLayer;
use tracing_subscriber::{
EnvFilter, Layer,
layer::SubscriberExt,
util::SubscriberInitExt,
};
use crate::config::Config;
pub struct TelemetryGuard {
pub tracer_provider: SdkTracerProvider,
pub logger_provider: SdkLoggerProvider,
pub meter_provider: SdkMeterProvider,
}
impl TelemetryGuard {
pub fn shutdown(&self) {
if let Err(e) = self.tracer_provider.shutdown() {
eprintln!("Error shutting down tracer provider: {e}");
}
if let Err(e) = self.logger_provider.shutdown() {
eprintln!("Error shutting down logger provider: {e}");
}
if let Err(e) = self.meter_provider.shutdown() {
eprintln!("Error shutting down meter provider: {e}");
}
}
}
pub fn init_telemetry(
config: &Config,
) -> anyhow::Result {
let resource = Resource::builder()
.with_service_name(config.otel_service_name.clone())
.with_attribute(KeyValue::new("service.version", "1.0.0"))
.with_attribute(KeyValue::new(
"service.namespace", "examples",
))
.with_attribute(KeyValue::new(
"deployment.environment",
config.environment.clone(),
))
.with_attribute(KeyValue::new(
"environment",
config.environment.clone(),
))
.build();
// --- Traces ---
let trace_exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(&config.otel_exporter_endpoint)
.with_timeout(Duration::from_secs(10))
.build()?;
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(trace_exporter)
.with_resource(resource.clone())
.build();
global::set_tracer_provider(tracer_provider.clone());
// --- Metrics ---
let metric_exporter = opentelemetry_otlp::MetricExporter::builder()
.with_tonic()
.with_endpoint(&config.otel_exporter_endpoint)
.with_timeout(Duration::from_secs(10))
.build()?;
let metric_reader = PeriodicReader::builder(metric_exporter)
.with_interval(Duration::from_secs(15))
.build();
let meter_provider = SdkMeterProvider::builder()
.with_reader(metric_reader)
.with_resource(resource.clone())
.build();
global::set_meter_provider(meter_provider.clone());
// --- Logs ---
let log_exporter = opentelemetry_otlp::LogExporter::builder()
.with_tonic()
.with_endpoint(&config.otel_exporter_endpoint)
.with_timeout(Duration::from_secs(10))
.build()?;
let logger_provider = SdkLoggerProvider::builder()
.with_batch_exporter(log_exporter)
.with_resource(resource)
.build();
// Bridge tracing logs to OpenTelemetry
let otel_log_layer =
OpenTelemetryTracingBridge::new(&logger_provider);
let tracer = global::tracer(config.otel_service_name.clone());
let telemetry_layer = OpenTelemetryLayer::new(tracer);
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| {
EnvFilter::new("info,tower_http=debug")
});
let fmt_layer = if config.is_production() {
tracing_subscriber::fmt::layer().json().boxed()
} else {
tracing_subscriber::fmt::layer().pretty().boxed()
};
tracing_subscriber::registry()
.with(env_filter)
.with(telemetry_layer)
.with(otel_log_layer)
.with(fmt_layer)
.init();
tracing::info!(
service = %config.otel_service_name,
endpoint = %config.otel_exporter_endpoint,
"Telemetry initialized with OTLP trace, metric, \
and log export"
);
Ok(TelemetryGuard {
tracer_provider,
logger_provider,
meter_provider,
})
}
```
Key points:
- **`TelemetryGuard`** holds all three providers and flushes pending telemetry
on shutdown - call `shutdown()` before process exit to avoid losing the final
batch
- **`OpenTelemetryLayer`** converts `tracing` spans into OpenTelemetry spans
with proper parent-child relationships
- **`OpenTelemetryTracingBridge`** routes structured log events (from
`tracing::info!`, `tracing::warn!`, etc.) to the OTLP log exporter
- **`EnvFilter`** respects `RUST_LOG` environment variable for runtime log level
control
#### Application Startup
Wire the telemetry guard into your `main` function and ensure graceful shutdown:
```rust showLineNumbers title="src/main.rs"
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = Config::from_env();
let telemetry_guard = init_telemetry(&config)?;
tracing::info!(
port = config.port,
environment = %config.environment,
"Starting ai-report-generator"
);
// ... build router, start server ...
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
tracing::info!("Server shutdown complete");
telemetry_guard.shutdown();
Ok(())
}
```
### Custom LLM Instrumentation
Rust has no auto-instrumentation libraries for LLM SDKs. You create manual spans
following the
[OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/).
This gives you the same standardized telemetry that Python and Node.js
auto-instrumentors produce.
#### GenAI Span Attributes
Every LLM call gets a `gen_ai.chat` span with these attributes:
| Attribute | Type | Description |
| -------------------------------- | ------ | ----------------------------------- |
| `gen_ai.operation.name` | string | Always `"chat"` for completions |
| `gen_ai.provider.name` | string | `"openai"`, `"anthropic"`, etc. |
| `gen_ai.request.model` | string | Model requested (e.g., `"gpt-4.1"`) |
| `gen_ai.request.temperature` | float | Sampling temperature |
| `gen_ai.request.max_tokens` | int | Max output tokens requested |
| `gen_ai.response.model` | string | Model actually used |
| `gen_ai.usage.input_tokens` | int | Prompt tokens consumed |
| `gen_ai.usage.output_tokens` | int | Completion tokens generated |
| `gen_ai.response.finish_reasons` | string | `"stop"`, `"length"`, etc. |
| `server.address` | string | API endpoint host |
| `server.port` | int | API endpoint port |
#### Creating GenAI Spans
Use `tracing::info_span!` with `tracing::field::Empty` for attributes that are
only known after the LLM call completes:
```rust showLineNumbers title="src/llm/client.rs"
use opentelemetry::KeyValue;
use tracing::Instrument;
use tracing_opentelemetry::OpenTelemetrySpanExt;
pub async fn generate_once(
&self,
provider: &dyn Provider,
provider_name: &str,
req: &GenerateRequest,
) -> anyhow::Result {
let span_display_name = format!("gen_ai.chat {}", req.model);
let start = std::time::Instant::now();
let span = tracing::info_span!(
"gen_ai.chat",
otel.name = %span_display_name,
gen_ai.operation.name = "chat",
gen_ai.provider.name = %provider_name,
gen_ai.request.model = %req.model,
gen_ai.request.temperature = req.temperature,
gen_ai.request.max_tokens = req.max_tokens as i64,
server.address = %server_addr,
server.port = server_port,
// Filled after LLM response
gen_ai.response.model = tracing::field::Empty,
gen_ai.usage.input_tokens = tracing::field::Empty,
gen_ai.usage.output_tokens = tracing::field::Empty,
gen_ai.usage.cost_usd = tracing::field::Empty,
gen_ai.response.finish_reasons = tracing::field::Empty,
report.stage = %req.stage,
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
);
// Record prompt and system instructions as span events
{
let mut user_event_attrs = vec![KeyValue::new(
"gen_ai.prompt",
truncate(&req.prompt, 1000),
)];
if !req.system.is_empty() {
user_event_attrs.push(KeyValue::new(
"gen_ai.system_instructions",
truncate(&req.system, 500),
));
}
span.add_event(
"gen_ai.user.message",
user_event_attrs,
);
}
// Execute LLM call within the span
let result = provider
.generate(req)
.instrument(span.clone())
.await;
match result {
Ok(mut resp) => {
resp.provider = provider_name.to_string();
resp.cost_usd = calculate_cost(
&resp.model,
resp.input_tokens,
resp.output_tokens,
);
// Fill response attributes
span.record(
"gen_ai.response.model",
resp.model.as_str(),
);
span.record(
"gen_ai.usage.input_tokens",
resp.input_tokens as i64,
);
span.record(
"gen_ai.usage.output_tokens",
resp.output_tokens as i64,
);
span.record(
"gen_ai.usage.cost_usd",
resp.cost_usd,
);
if !resp.finish_reason.is_empty() {
span.record(
"gen_ai.response.finish_reasons",
resp.finish_reason.as_str(),
);
}
// Record completion as a span event
span.add_event(
"gen_ai.assistant.message",
vec![KeyValue::new(
"gen_ai.completion",
truncate(&resp.content, 2000),
)],
);
Ok(resp)
}
Err(err) => {
span.record("otel.status_code", "ERROR");
span.record(
"error.type",
classify_error(&err),
);
GEN_AI_ERROR_COUNT.add(
1,
&[
KeyValue::new(
"gen_ai.provider.name",
provider_name.to_string(),
),
KeyValue::new(
"gen_ai.request.model",
req.model.clone(),
),
],
);
Err(err)
}
}
}
```
Key patterns:
- **`tracing::field::Empty`** declares span fields that are filled later with
`span.record()` - this is how you handle attributes that depend on the LLM
response
- **`.instrument(span.clone())`** executes the async LLM call within the span
context, so the span duration matches the actual API call
- **`span.add_event()`** uses `opentelemetry::KeyValue` (not `tracing` fields)
to record prompt and completion content as structured span events
- **`otel.name`** overrides the span display name in your trace viewer to show
the model name
> **Note:** The complete `generate_once` function also records GenAI metrics
> (token usage, cost, duration) alongside the span attributes shown above. See
> [Token and Cost Tracking](#token-and-cost-tracking) for the metric recording
> code that runs in the same `Ok(resp)` branch.
#### Error Classification
Classify LLM errors into standardized types for filtering and alerting:
```rust showLineNumbers title="src/llm/client.rs"
fn classify_error(err: &anyhow::Error) -> &'static str {
let msg = err.to_string().to_lowercase();
if msg.contains("rate limit") || msg.contains("429") {
"rate_limit"
} else if msg.contains("timeout")
|| msg.contains("timed out")
|| msg.contains("deadline")
{
"timeout"
} else if msg.contains("401")
|| msg.contains("403")
|| msg.contains("auth")
|| msg.contains("api key")
{
"auth_error"
} else if msg.contains("400")
|| msg.contains("422")
|| msg.contains("invalid")
{
"invalid_request"
} else if msg.contains("500")
|| msg.contains("502")
|| msg.contains("503")
|| msg.contains("server")
{
"server_error"
} else if msg.contains("connect")
|| msg.contains("dns")
|| msg.contains("network")
|| msg.contains("reset")
{
"network_error"
} else {
"unknown_error"
}
}
```
This lets you alert on `error.type = "rate_limit"` separately from
`error.type = "timeout"` in Scout dashboards.
### Token and Cost Tracking
Define GenAI metrics following the OpenTelemetry GenAI semantic conventions.
These metrics power dashboards for token consumption, cost attribution, and
provider reliability.
#### Metric Definitions
```rust showLineNumbers title="src/telemetry/metrics.rs"
use opentelemetry::{
global,
metrics::{Counter, Histogram, Meter},
};
use std::sync::LazyLock;
pub static METER: LazyLock =
LazyLock::new(|| global::meter("ai-report-generator"));
// --- GenAI Contract Metrics (6 required) ---
pub static GEN_AI_TOKEN_USAGE: LazyLock> =
LazyLock::new(|| {
METER
.f64_histogram("gen_ai.client.token.usage")
.with_description(
"Number of tokens used per LLM call",
)
.with_unit("{token}")
.build()
});
pub static GEN_AI_OPERATION_DURATION: LazyLock> =
LazyLock::new(|| {
METER
.f64_histogram("gen_ai.client.operation.duration")
.with_description(
"Duration of LLM operations in seconds",
)
.with_unit("s")
.build()
});
pub static GEN_AI_COST: LazyLock> =
LazyLock::new(|| {
METER
.f64_counter("gen_ai.client.cost")
.with_description(
"Estimated cost of LLM operations in USD",
)
.with_unit("usd")
.build()
});
pub static GEN_AI_RETRY_COUNT: LazyLock> =
LazyLock::new(|| {
METER
.u64_counter("gen_ai.client.retry.count")
.with_description("Number of LLM call retries")
.with_unit("{retry}")
.build()
});
pub static GEN_AI_FALLBACK_COUNT: LazyLock> =
LazyLock::new(|| {
METER
.u64_counter("gen_ai.client.fallback.count")
.with_description(
"Number of LLM fallback activations",
)
.with_unit("{fallback}")
.build()
});
pub static GEN_AI_ERROR_COUNT: LazyLock> =
LazyLock::new(|| {
METER
.u64_counter("gen_ai.client.error.count")
.with_description("Number of LLM call errors")
.with_unit("{error}")
.build()
});
```
#### Recording Token and Cost Metrics
After each successful LLM call, record token usage with provider and model
dimensions:
```rust showLineNumbers title="src/llm/client.rs"
use crate::telemetry::metrics::{
GEN_AI_COST, GEN_AI_OPERATION_DURATION, GEN_AI_TOKEN_USAGE,
};
// Inside the Ok(resp) branch of generate_once:
let op_kv = KeyValue::new("gen_ai.operation.name", "chat");
let provider_kv = KeyValue::new(
"gen_ai.provider.name",
provider_name.to_string(),
);
let model_kv = KeyValue::new(
"gen_ai.request.model",
resp.model.clone(),
);
GEN_AI_TOKEN_USAGE.record(
f64::from(resp.input_tokens),
&[
KeyValue::new("gen_ai.token.type", "input"),
op_kv.clone(),
provider_kv.clone(),
model_kv.clone(),
],
);
GEN_AI_TOKEN_USAGE.record(
f64::from(resp.output_tokens),
&[
KeyValue::new("gen_ai.token.type", "output"),
op_kv.clone(),
provider_kv.clone(),
model_kv.clone(),
],
);
GEN_AI_OPERATION_DURATION.record(
duration,
&[op_kv.clone(), provider_kv.clone(), model_kv.clone()],
);
GEN_AI_COST.add(
resp.cost_usd,
&[op_kv, provider_kv, model_kv],
);
```
The `gen_ai.token.type` dimension (`"input"` or `"output"`) lets you build
dashboards that break down token consumption by direction, provider, and model.
#### Cost Calculation
Load model pricing from a configuration file and calculate cost per call:
```rust showLineNumbers title="src/llm/pricing.rs"
pub fn calculate_cost(
model: &str,
input_tokens: u32,
output_tokens: u32,
) -> f64 {
// PRICING maps model name to PriceEntry { input, output }
// where input/output are price per million tokens
match PRICING.get(model) {
Some(entry) => {
(f64::from(input_tokens) * entry.input
/ 1_000_000.0)
+ (f64::from(output_tokens) * entry.output
/ 1_000_000.0)
}
None => 0.0,
}
}
```
This feeds the `gen_ai.client.cost` counter metric for per-model and
per-provider cost dashboards.
### Pipeline Observability
AI applications typically involve multi-stage pipelines. Each stage gets its own
span, creating a clear parent-child hierarchy in your traces.
#### Pipeline Orchestrator
The orchestrator span wraps the entire pipeline and extracts the trace ID for
database correlation:
```rust showLineNumbers title="src/pipeline/orchestrator.rs"
use opentelemetry::trace::TraceContextExt;
use tracing_opentelemetry::OpenTelemetrySpanExt;
#[tracing::instrument(
name = "pipeline report",
skip(pool, llm_client),
fields(
report.id,
report.indicators_count,
report.duration_ms,
)
)]
pub async fn generate_report(
pool: &PgPool,
llm_client: &LlmClient,
model_capable: &str,
model_fast: &str,
request: &ReportRequest,
) -> Result {
let start = std::time::Instant::now();
// Extract trace ID for database correlation
let span = tracing::Span::current();
let context = span.context();
let otel_span = context.span();
let trace_id = otel_span
.span_context()
.trace_id()
.to_string();
// Stage 1: Retrieve data from PostgreSQL
let data = retrieve::retrieve(
pool, &request.indicators,
request.start_date, request.end_date,
).await?;
// Stage 2: Analyze trends via LLM (fast model)
let analysis = analyze::analyze(
llm_client, model_fast, &data.indicators,
).await?;
// Stage 3: Generate narrative via LLM (capable model)
let narrative = generate::generate(
llm_client, model_capable,
&data.indicators, &analysis,
).await?;
// Stage 4: Format final report
let duration = start.elapsed();
let report = format::format_report(FormatParams {
trace_id,
duration,
// ...
})?;
// Record domain metrics
REPORT_GENERATION_DURATION.record(
duration.as_secs_f64(), &[],
);
REPORT_DATA_POINTS.record(
report.total_data_points as f64, &[],
);
REPORT_SECTIONS.record(
report.sections.len() as f64, &[],
);
span.record("report.id", report.id.to_string());
span.record(
"report.indicators_count",
report.indicators_used.len(),
);
span.record(
"report.duration_ms",
report.generation_duration_ms,
);
Ok(report)
}
```
#### Pipeline Stage Spans
Each stage uses `#[tracing::instrument]` with stage-specific fields:
```rust showLineNumbers title="src/pipeline/retrieve.rs"
#[tracing::instrument(
name = "pipeline_stage retrieve",
skip(pool),
fields(
pipeline.stage = "retrieve",
report.indicators_count,
report.data_points,
)
)]
pub async fn retrieve(
pool: &PgPool,
indicator_codes: &[String],
start_date: NaiveDate,
end_date: NaiveDate,
) -> Result {
let indicators = query_indicator_data(
pool, indicator_codes, start_date, end_date,
)
.await
.map_err(AppError::Database)?;
let total_data_points: usize =
indicators.iter().map(|i| i.values.len()).sum();
let span = tracing::Span::current();
span.record(
"report.indicators_count",
indicators.len(),
);
span.record("report.data_points", total_data_points);
Ok(RetrieveResult { indicators, total_data_points })
}
```
```rust showLineNumbers title="src/pipeline/analyze.rs"
#[tracing::instrument(
name = "pipeline_stage analyze",
skip(llm_client, data),
fields(
pipeline.stage = "analyze",
analysis.trends_found,
analysis.key_findings,
)
)]
pub async fn analyze(
llm_client: &LlmClient,
model: &str,
data: &[IndicatorData],
) -> Result {
// Build data summary and prompt (omitted for brevity)
let system = include_str!("../../data/schema-context.txt")
.to_string();
let resp = llm_client
.generate(&GenerateRequest {
model: model.to_string(),
system,
prompt,
temperature: 0.3,
max_tokens: 2048,
stage: "analyze".to_string(),
})
.await
.map_err(|e| AppError::Llm(e.to_string()))?;
// Parse JSON response and preserve provider
let provider = resp.provider.clone();
let mut analysis = parse_analysis_response(
&resp.content,
resp.input_tokens,
resp.output_tokens,
resp.cost_usd,
)?;
analysis.provider = provider;
let span = tracing::Span::current();
span.record(
"analysis.trends_found",
analysis.trends.len(),
);
span.record(
"analysis.key_findings",
analysis.key_findings.len(),
);
Ok(analysis)
}
```
The resulting trace shows clear parent-child relationships: `pipeline report` →
`pipeline_stage analyze` → `gen_ai.chat gpt-4.1-mini`. Each stage is
independently timed and attributed.
#### Trace ID Correlation
Store the OpenTelemetry trace ID alongside business data in your database. This
lets you jump from a database record directly to its trace in Scout:
```sql showLineNumbers title="db/schema.sql"
CREATE TABLE reports (
id UUID PRIMARY KEY,
title TEXT NOT NULL,
-- ... other fields ...
trace_id TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
Extract the trace ID from the current span context:
```rust showLineNumbers title="Extract trace ID"
use opentelemetry::trace::TraceContextExt;
use tracing_opentelemetry::OpenTelemetrySpanExt;
let span = tracing::Span::current();
let context = span.context();
let trace_id = context
.span()
.span_context()
.trace_id()
.to_string();
```
### Retry and Fallback Observability
LLM APIs are unreliable. Retries and provider fallbacks must be observable to
understand real-world reliability.
#### Retry with Exponential Backoff
```rust showLineNumbers title="src/llm/client.rs"
pub async fn generate_with_retry(
&self,
provider: &dyn Provider,
provider_name: &str,
req: &GenerateRequest,
) -> anyhow::Result {
let max_retries: u32 = 3;
let mut last_err = None;
for attempt in 0..max_retries {
match self.generate_once(
provider, provider_name, req,
).await {
Ok(resp) => return Ok(resp),
Err(err) => {
tracing::warn!(
attempt = attempt + 1,
max_retries,
provider = provider_name,
model = %req.model,
error = %err,
"LLM call failed, retrying"
);
if attempt > 0 {
GEN_AI_RETRY_COUNT.add(1, &[
KeyValue::new(
"gen_ai.provider.name",
provider_name.to_string(),
),
KeyValue::new(
"gen_ai.request.model",
req.model.clone(),
),
]);
}
last_err = Some(err);
if attempt < max_retries - 1 {
// Exponential backoff: 1s, 2s, 4s
// (capped at 10s)
let base = Duration::from_secs(1)
* 2u32.pow(attempt);
let base = base.min(
Duration::from_secs(10),
);
// 25% jitter to avoid thundering herd
let jitter_ms = fastrand::u64(
0..=base.as_millis() as u64 / 4,
);
let delay = base
+ Duration::from_millis(jitter_ms);
tokio::time::sleep(delay).await;
}
}
}
}
Err(last_err.unwrap_or_else(|| {
anyhow::anyhow!("all retries exhausted")
}))
}
```
#### Provider Fallback
When the primary provider fails after all retries, fall back to a secondary
provider:
```rust showLineNumbers title="src/llm/client.rs"
pub async fn generate(
&self,
req: &GenerateRequest,
) -> anyhow::Result {
let result = self.generate_with_retry(
self.primary.as_ref(),
&self.primary_provider,
req,
).await;
match result {
Ok(resp) => Ok(resp),
Err(primary_err) => {
if let Some(ref fallback) = self.fallback {
tracing::warn!(
primary_provider = %self.primary_provider,
fallback_provider = %self.fallback_provider,
error = %primary_err,
"Primary provider failed, falling back"
);
GEN_AI_FALLBACK_COUNT.add(1, &[]);
let fallback_req = GenerateRequest {
model: self.fallback_model.clone(),
..req.clone()
};
self.generate_with_retry(
fallback.as_ref(),
&self.fallback_provider,
&fallback_req,
).await
} else {
Err(anyhow::anyhow!(
"primary provider {} failed \
after retries: {}",
self.primary_provider,
primary_err
))
}
}
}
}
```
Each retry creates a new `gen_ai.chat` span, so you see every attempt in the
trace. The `gen_ai.client.retry.count` and `gen_ai.client.fallback.count`
metrics let you build reliability dashboards and alert on degradation.
### HTTP Instrumentation
Use tower-http's `TraceLayer` with custom `MakeSpan` and `OnResponse`
implementations to capture HTTP metrics and set OpenTelemetry status codes:
```rust showLineNumbers title="src/main.rs"
use tower_http::trace::{MakeSpan, OnResponse, TraceLayer};
#[derive(Clone)]
struct HttpMakeSpan;
impl MakeSpan for HttpMakeSpan {
fn make_span(&mut self, request: &Request) -> Span {
let method = request.method().as_str();
let path = request.uri().path();
tracing::info_span!(
"HTTP request",
otel.name = %format!("{} {}", method, path),
http.method = %method,
http.route = %path,
http.target = %request.uri(),
http.scheme = "http",
http.flavor = ?request.version(),
http.user_agent = request.headers()
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
http.response.status_code = tracing::field::Empty,
otel.status_code = tracing::field::Empty,
)
}
}
#[derive(Clone)]
struct HttpOnResponse;
impl OnResponse for HttpOnResponse {
fn on_response(
self,
response: &Response,
latency: Duration,
span: &Span,
) {
let status = response.status().as_u16();
span.record(
"http.response.status_code",
status as i64,
);
if status >= 500 {
span.record("otel.status_code", "ERROR");
} else {
span.record("otel.status_code", "OK");
}
let latency_ms = latency.as_secs_f64() * 1000.0;
let status_class =
format!("{}xx", status / 100);
HTTP_REQUESTS_TOTAL.add(1, &[
KeyValue::new(
"http.status_code",
status.to_string(),
),
KeyValue::new(
"http.status_class",
status_class.clone(),
),
]);
HTTP_REQUEST_DURATION.record(latency_ms, &[
KeyValue::new(
"http.status_code",
status.to_string(),
),
KeyValue::new(
"http.status_class",
status_class,
),
]);
tracing::info!(
http.response.status_code = status,
latency_ms = latency_ms,
"finished processing request"
);
}
}
// Apply to router
let app = Router::new()
.route("/api/reports", post(create_report))
.route("/api/reports", get(list_reports))
.layer(
TraceLayer::new_for_http()
.make_span_with(HttpMakeSpan)
.on_response(HttpOnResponse),
);
```
### Multi-Provider LLM Architecture
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
The provider trait pattern lets you support multiple LLM backends (OpenAI,
Anthropic, Google, Ollama) with consistent telemetry:
```rust showLineNumbers title="src/llm/mod.rs"
#[derive(Debug, Clone)]
pub struct GenerateRequest {
pub model: String,
pub system: String,
pub prompt: String,
pub temperature: f32,
pub max_tokens: u32,
pub stage: String,
}
#[derive(Debug, Clone)]
pub struct GenerateResponse {
pub content: String,
pub model: String,
pub input_tokens: u32,
pub output_tokens: u32,
pub cost_usd: f64,
pub finish_reason: String,
pub provider: String,
}
#[async_trait::async_trait]
pub trait Provider: Send + Sync {
async fn generate(
&self,
req: &GenerateRequest,
) -> anyhow::Result;
fn name(&self) -> &str;
}
```
The `LlmClient` wraps this trait with retry, fallback, and telemetry logic. Each
provider implementation only needs to implement the `Provider` trait - all
observability happens in the client layer.
```mdx-code-block
```
Uses the `async-openai` crate. Google and Ollama work through OpenAI-compatible
endpoints with a different base URL:
```rust showLineNumbers title="src/llm/openai.rs"
use async_openai::{
Client,
config::OpenAIConfig,
types::chat::{
ChatCompletionRequestMessage,
ChatCompletionRequestSystemMessage,
ChatCompletionRequestSystemMessageContent,
ChatCompletionRequestUserMessage,
ChatCompletionRequestUserMessageContent,
CreateChatCompletionRequest,
},
};
pub struct OpenAIProvider {
client: Client,
provider_name: String,
}
impl OpenAIProvider {
pub fn new(api_key: &str) -> Self {
let config = OpenAIConfig::new()
.with_api_key(api_key);
Self {
client: Client::with_config(config),
provider_name: "openai".to_string(),
}
}
pub fn new_google(api_key: &str) -> Self {
let config = OpenAIConfig::new()
.with_api_key(api_key)
.with_api_base(
"https://generativelanguage.googleapis.com\
/v1beta/openai",
);
Self {
client: Client::with_config(config),
provider_name: "google".to_string(),
}
}
pub fn new_ollama(base_url: &str) -> Self {
let config = OpenAIConfig::new()
.with_api_key("ollama")
.with_api_base(format!("{base_url}/v1"));
Self {
client: Client::with_config(config),
provider_name: "ollama".to_string(),
}
}
}
#[async_trait::async_trait]
impl Provider for OpenAIProvider {
async fn generate(
&self,
req: &GenerateRequest,
) -> anyhow::Result {
let messages = vec![
ChatCompletionRequestMessage::System(
ChatCompletionRequestSystemMessage {
content:
ChatCompletionRequestSystemMessageContent::Text(
req.system.clone(),
),
name: None,
},
),
ChatCompletionRequestMessage::User(
ChatCompletionRequestUserMessage {
content:
ChatCompletionRequestUserMessageContent::Text(
req.prompt.clone(),
),
name: None,
},
),
];
#[allow(deprecated)]
let request = CreateChatCompletionRequest {
model: req.model.clone(),
messages,
temperature: Some(req.temperature),
max_completion_tokens: Some(req.max_tokens),
..Default::default()
};
let response =
self.client.chat().create(request).await?;
let content = response
.choices
.first()
.and_then(|c| c.message.content.clone())
.unwrap_or_default();
let finish_reason = response
.choices
.first()
.and_then(|c| c.finish_reason)
.map(|r| format!("{r:?}").to_lowercase())
.unwrap_or_default();
let (input_tokens, output_tokens) =
match &response.usage {
Some(u) => (u.prompt_tokens, u.completion_tokens),
None => (0, 0),
};
Ok(GenerateResponse {
content,
model: response.model,
input_tokens,
output_tokens,
cost_usd: 0.0,
finish_reason,
provider: String::new(),
})
}
fn name(&self) -> &str {
&self.provider_name
}
}
```
```mdx-code-block
```
Uses raw `reqwest` HTTP client since Anthropic has a different API format:
```rust showLineNumbers title="src/llm/anthropic.rs"
use reqwest::header::{
CONTENT_TYPE, HeaderMap, HeaderValue,
};
pub struct AnthropicProvider {
client: reqwest::Client,
api_key: String,
}
impl AnthropicProvider {
pub fn new(api_key: &str) -> Self {
Self {
client: reqwest::Client::new(),
api_key: api_key.to_string(),
}
}
}
#[async_trait::async_trait]
impl Provider for AnthropicProvider {
async fn generate(
&self,
req: &GenerateRequest,
) -> anyhow::Result {
let mut headers = HeaderMap::new();
headers.insert(
"x-api-key",
HeaderValue::from_str(&self.api_key)?,
);
headers.insert(
"anthropic-version",
HeaderValue::from_static("2023-06-01"),
);
headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
let body = serde_json::json!({
"model": req.model,
"max_tokens": req.max_tokens,
"system": req.system,
"messages": [{
"role": "user",
"content": req.prompt,
}],
});
let response = self
.client
.post("https://api.anthropic.com/v1/messages")
.headers(headers)
.json(&body)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let error_body =
response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"Anthropic API error ({}): {}",
status,
error_body
));
}
let resp: serde_json::Value =
response.json().await?;
let content = resp["content"]
.as_array()
.and_then(|arr| arr.first())
.and_then(|c| c["text"].as_str())
.unwrap_or_default()
.to_string();
let input_tokens = resp["usage"]["input_tokens"]
.as_u64()
.unwrap_or(0) as u32;
let output_tokens =
resp["usage"]["output_tokens"]
.as_u64()
.unwrap_or(0) as u32;
Ok(GenerateResponse {
content,
model: resp["model"]
.as_str()
.unwrap_or(&req.model)
.to_string(),
input_tokens,
output_tokens,
cost_usd: 0.0,
finish_reason: resp["stop_reason"]
.as_str()
.unwrap_or_default()
.to_string(),
provider: String::new(),
})
}
fn name(&self) -> &str {
"anthropic"
}
}
```
```mdx-code-block
```
Provider initialization selects the backend based on configuration:
```rust showLineNumbers title="src/main.rs - Provider initialization"
let primary: Arc = match config
.llm_provider
.as_str()
{
"anthropic" => Arc::new(
AnthropicProvider::new(api_key),
),
"google" => Arc::new(
OpenAIProvider::new_google(api_key),
),
"ollama" => Arc::new(
OpenAIProvider::new_ollama(&base_url),
),
_ => Arc::new(OpenAIProvider::new(api_key)),
};
let llm_client = Arc::new(LlmClient {
primary,
fallback,
primary_provider: config.llm_provider.clone(),
fallback_provider: config.fallback_provider.clone(),
fallback_model: config.fallback_model.clone(),
});
```
### PII and Security
LLM prompts and completions often contain sensitive data. Truncate and sanitize
content before recording in span events.
#### Content Truncation
Always truncate prompt and completion content before adding to span events to
avoid oversized spans and limit sensitive data exposure:
```rust showLineNumbers title="src/llm/client.rs"
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
// Safe for multi-byte UTF-8
s.char_indices()
.take_while(|&(i, _)| i < max)
.map(|(_, c)| c)
.collect()
}
}
// Usage in span events
span.add_event(
"gen_ai.user.message",
vec![KeyValue::new(
"gen_ai.prompt",
truncate(&req.prompt, 1000),
)],
);
span.add_event(
"gen_ai.assistant.message",
vec![KeyValue::new(
"gen_ai.completion",
truncate(&resp.content, 2000),
)],
);
```
#### Security Considerations
- **Truncate prompts** to 1000 characters and completions to 2000 characters to
limit data exposure in telemetry
- **Never record API keys** in span attributes or events - load keys from
environment variables, not configuration files
- **Use the OpenTelemetry Collector** `filter` processor to drop sensitive spans
before they leave your network
- **Strip HTTP headers** like `Authorization` from HTTP spans - tower-http's
`TraceLayer` does not record headers by default, which is the safe behavior
- **Consider disabling prompt/completion events** in production if your data is
subject to GDPR, HIPAA, or PCI-DSS compliance requirements by removing the
`add_event` calls
### Running Your Application
```mdx-code-block
```
Run with console output and debug logging:
```bash showLineNumbers title="Terminal"
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_SERVICE_NAME=ai-report-generator
export LLM_PROVIDER=openai
export OPENAI_API_KEY=sk-...
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/report_generator
cargo run
```
```mdx-code-block
```
Set environment-specific configuration:
```bash showLineNumbers title="Terminal"
export SCOUT_ENVIRONMENT=production
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
export OTEL_SERVICE_NAME=ai-report-generator
export RUST_LOG=info,tower_http=info
cargo run --release
```
```mdx-code-block
```
Deploy the application, PostgreSQL, and OpenTelemetry Collector together:
```yaml showLineNumbers title="compose.yml"
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- APP_PORT=8080
- DATABASE_URL=postgres://postgres:postgres@postgres:5432/report_generator?sslmode=disable
- LLM_PROVIDER=${LLM_PROVIDER:-openai}
- LLM_MODEL_CAPABLE=${LLM_MODEL_CAPABLE:-gpt-4.1}
- LLM_MODEL_FAST=${LLM_MODEL_FAST:-gpt-4.1-mini}
- FALLBACK_PROVIDER=${FALLBACK_PROVIDER:-anthropic}
- FALLBACK_MODEL=${FALLBACK_MODEL:-claude-haiku-4-5-20251001}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- OTEL_SERVICE_NAME=ai-report-generator
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
- SCOUT_ENVIRONMENT=${SCOUT_ENVIRONMENT:-development}
depends_on:
postgres:
condition: service_healthy
otel-collector:
condition: service_started
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:8080/api/health",
]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
postgres:
image: postgres:18
environment:
POSTGRES_DB: report_generator
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql
- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
- ./db/seed.sql:/docker-entrypoint-initdb.d/02-seed.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
otel-collector:
image: otel/opentelemetry-collector-contrib:0.146.1
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./config/otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
ports:
- "4317:4317"
- "4318:4318"
- "13133:13133"
- "55679:55679"
environment:
- SCOUT_CLIENT_ID=${SCOUT_CLIENT_ID:-}
- SCOUT_CLIENT_SECRET=${SCOUT_CLIENT_SECRET:-}
- SCOUT_TOKEN_URL=${SCOUT_TOKEN_URL:-https://auth.base14.io/oauth/token}
- SCOUT_ENDPOINT=${SCOUT_ENDPOINT:-https://collector.base14.io}
- SCOUT_ENVIRONMENT=${SCOUT_ENVIRONMENT:-development}
volumes:
pgdata:
```
#### OpenTelemetry Collector Configuration
```yaml showLineNumbers title="config/otel-collector-config.yaml"
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
filter/noisy:
error_mode: ignore
traces:
span:
- 'IsMatch(name, ".*/health.*")'
batch:
timeout: 10s
send_batch_size: 1024
send_batch_max_size: 2048
attributes:
actions:
- key: deployment.environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
- key: environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
exporters:
otlp_http/b14:
endpoint: ${SCOUT_ENDPOINT}
auth:
authenticator: oauth2client
compression: gzip
timeout: 30s
retry_on_failure:
enabled: true
initial_interval: 1s
max_interval: 30s
max_elapsed_time: 300s
debug:
verbosity: detailed
sampling_initial: 100
sampling_thereafter: 100
extensions:
health_check:
endpoint: 0.0.0.0:13133
zpages:
endpoint: 0.0.0.0:55679
oauth2client:
client_id: ${SCOUT_CLIENT_ID}
client_secret: ${SCOUT_CLIENT_SECRET}
token_url: ${SCOUT_TOKEN_URL}
endpoint_params:
audience: b14collector
timeout: 10s
service:
extensions: [health_check, zpages, oauth2client]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, filter/noisy, attributes, batch]
exporters: [otlp_http/b14, debug]
metrics:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlp_http/b14, debug]
logs:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlp_http/b14, debug]
```
#### Dockerfile
Multi-stage build for minimal production images:
```dockerfile showLineNumbers title="Dockerfile"
FROM rust:1.92-alpine AS builder
WORKDIR /build
RUN apk add --no-cache musl-dev openssl-dev \
openssl-libs-static pkgconfig protobuf-dev
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release 2>/dev/null || true
RUN rm -rf src
COPY src ./src
COPY data ./data
RUN touch src/main.rs && cargo build --release --bin server
FROM alpine:3.23
RUN apk add --no-cache ca-certificates tzdata wget \
&& adduser -D -g '' -u 1001 appuser
WORKDIR /app
COPY --from=builder /build/target/release/server .
COPY --from=builder /build/_shared/pricing.json /app/_shared/pricing.json
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s \
--start-period=15s --retries=3 \
CMD wget -q --spider http://localhost:8080/api/health \
|| exit 1
CMD ["./server"]
```
```mdx-code-block
```
### Troubleshooting
#### Verify Telemetry Is Working
Check that spans are being exported by looking for the telemetry initialization
log:
```bash showLineNumbers title="Terminal"
docker compose logs app 2>&1 | grep "Telemetry initialized"
```
#### Enable Debug Logging
```bash showLineNumbers title="Terminal"
export RUST_LOG=debug,h2=info,hyper=info
```
This enables debug output for your application while suppressing noisy HTTP/2
and Hyper transport logs.
#### Check Collector Health
```bash showLineNumbers title="Terminal"
curl http://localhost:13133/health
# {"status":"Server available","..."}
```
##### Issue: No spans appearing in Scout
**Solutions:**
1. Verify `OTEL_EXPORTER_OTLP_ENDPOINT` points to the collector (e.g.,
`http://otel-collector:4317` in Docker, `http://localhost:4317` locally)
2. Check that `telemetry_guard.shutdown()` is called before process exit - the
batch exporter flushes on shutdown
3. Add a `debug` exporter to the collector config to see incoming spans in
collector logs
##### Issue: Spans missing parent-child relationships
**Solutions:**
1. Ensure async operations use `.instrument(span.clone())` to propagate the span
context across `await` points
2. Use `#[tracing::instrument]` on async functions - it automatically creates
child spans
3. Enable `tracing` feature on `tokio` in Cargo.toml:
`tokio = { features = ["tracing"] }`
##### Issue: Metrics not appearing
**Solutions:**
1. Verify `PeriodicReader` interval - metrics are batched and exported every 15
seconds by default
2. Check that `global::set_meter_provider()` is called before any metric
instruments are created
3. Ensure the collector `metrics` pipeline is configured with the OTLP receiver
##### Issue: LLM span attributes are empty
**Solutions:**
1. Check that `span.record()` is called with the correct field name matching the
`tracing::info_span!` declaration
2. Verify `tracing::field::Empty` fields are declared in the span macro - you
cannot record fields that were not declared
3. Ensure `.instrument(span.clone())` is used, not
`.instrument(tracing::Span::current())`
### Performance Considerations
#### Expected Impact
| Metric | Without OTel | With OTel | Delta |
| ------------- | ------------ | --------- | ---------- |
| Latency (p99) | baseline | +0.5-1ms | < 1ms |
| Memory | baseline | +5-10 MB | minimal |
| CPU | baseline | +1-2% | negligible |
Rust's zero-cost abstractions mean OpenTelemetry overhead is minimal compared to
Python or Node.js. The batch exporter handles I/O asynchronously, so span
creation is nearly free.
#### Optimization Tips
##### 1. Use Batch Export
The default `with_batch_exporter()` is already optimal. Avoid
`SimpleSpanProcessor` in production - it blocks on every span.
##### 2. Tune Metric Export Interval
```rust showLineNumbers title="Adjust interval for high-throughput"
let metric_reader = PeriodicReader::builder(metric_exporter)
.with_interval(Duration::from_secs(30)) // 30s for lower overhead
.build();
```
##### 3. Filter Noisy Spans at the Collector
Drop health check and readiness probe spans in the collector rather than in
application code:
```yaml showLineNumbers title="config/otel-collector-config.yaml"
processors:
filter/noisy:
error_mode: ignore
traces:
span:
- 'IsMatch(name, ".*/health.*")'
```
##### 4. Limit Attribute Sizes
Truncate prompt and completion content to avoid oversized spans:
```rust showLineNumbers title="Truncation limits"
// Prompts: 1000 chars max
truncate(&req.prompt, 1000)
// Completions: 2000 chars max
truncate(&resp.content, 2000)
// System instructions: 500 chars max
truncate(&req.system, 500)
```
##### 5. Conditional Span Recording
Skip detailed span events in high-throughput scenarios:
```rust showLineNumbers title="Conditional recording"
if config.record_prompt_events {
span.add_event(
"gen_ai.user.message",
vec![KeyValue::new(
"gen_ai.prompt",
truncate(&req.prompt, 1000),
)],
);
}
```
### FAQ
#### How much overhead does OpenTelemetry add to Rust applications?
Less than 1ms per request in most cases. Rust's zero-cost abstractions and the
batch exporter design mean span creation is a few microseconds. The OTLP gRPC
export happens asynchronously and does not block request processing.
#### What Rust crates do I need for OpenTelemetry LLM tracing?
Yes. The `tracing` crate is the idiomatic Rust instrumentation API. The
`tracing-opentelemetry` crate bridges `tracing` spans to OpenTelemetry spans.
The `opentelemetry` crate provides the export pipeline (OTLP, metrics, logs).
You write code using `tracing` macros and the OpenTelemetry SDK handles export.
#### Should I use the tracing crate or OpenTelemetry API directly in Rust?
You can, but it is not recommended for Rust applications. The `tracing`
ecosystem integrates with Tokio, tower, SQLx, and most Rust libraries. Using the
OpenTelemetry API directly would miss automatic span propagation across async
boundaries and structured log integration.
#### Does Rust have auto-instrumentation for LLM APIs like Python does?
No. Rust does not have LLM auto-instrumentation libraries like Python's
`opentelemetry-instrumentation-anthropic` or Node.js instrumentors. You create
GenAI spans manually, which gives you full control over which attributes to
record and how to handle provider-specific response formats.
#### How do I track costs across multiple LLM providers?
Use the `gen_ai.client.cost` counter metric with `gen_ai.provider.name` and
`gen_ai.request.model` dimensions. Load pricing from a configuration file and
calculate cost per call based on input/output token counts. The metric
dimensions let you build per-provider and per-model cost dashboards.
#### Can I use Ollama (local models) with the same instrumentation?
Yes. The provider trait pattern abstracts away the backend. Ollama exposes an
OpenAI-compatible API, so you can use the same `async-openai` client pointed at
`http://localhost:11434/v1`. All GenAI spans and metrics work identically - only
`gen_ai.provider.name` and `server.address` change.
#### How do I reduce trace volume from LLM applications?
Use the collector's `filter` processor to drop noisy spans (health checks,
readiness probes). For high-volume applications, use the collector's
`probabilistic_sampler` processor. You can also disable prompt/completion span
events to reduce span size while keeping the core GenAI attributes.
#### What happens if the collector is unavailable?
The batch exporter buffers spans in memory and retries export. If the collector
remains unavailable, buffered spans are eventually dropped. Your application
continues to run normally - telemetry export is non-blocking. Configure the
collector with health checks and ensure it starts before your application in
Docker Compose.
#### How do I correlate traces with database records?
Extract the trace ID from the current span context using
`span.context().span().span_context().trace_id()` and store it in a `trace_id`
column in your database table. This lets you query Scout for the exact trace
that produced a specific database record.
#### How do I export OpenTelemetry data over HTTP instead of gRPC in Rust?
Yes. Replace `with_tonic()` with `with_http()` in the exporter builders and
point to port 4318 instead of 4317. gRPC is recommended for production because
it supports streaming and has lower overhead for high-volume telemetry.
### What's Next?
#### Advanced Topics
- [LLM Observability (Python)](../llm-observability) - Python equivalent with
auto-instrumentation patterns
- [Axum Instrumentation](../../instrument/apps/auto-instrumentation/axum.md) —
General Axum APM without AI-specific patterns
- [Rust Custom Instrumentation](../../instrument/apps/custom-instrumentation/rust.md)
- Manual OpenTelemetry SDK usage for Rust
#### Scout Platform Features
- [Creating Alerts](../creating-alerts-with-logx.md) - Set up alerts for LLM
error rates and cost thresholds
- [Dashboards and Alerts](../../operate/dashboards-and-alerts.md) - Build
dashboards for LLM metrics in Scout
#### Deployment and Operations
- [Docker Compose Setup](../../instrument/collector-setup/docker-compose-example.md)
- Collector deployment guide
- [Kubernetes Helm Setup](../../instrument/collector-setup/kubernetes-helm-setup.md)
- Production Kubernetes deployment
### Complete Example
The full working implementation is available in the examples repository:
**[rust/ai-report-generator](https://github.com/base-14/examples/tree/main/rust/ai-report-generator)**
This example implements:
- Axum REST API with 5 endpoints
- PostgreSQL with SQLx for data storage
- Multi-provider LLM support (OpenAI, Anthropic, Google, Ollama) with automatic
fallback
- 4-stage pipeline (retrieve → analyze → generate → format)
- Full GenAI semantic convention spans and metrics
- Three-pillar telemetry (traces, metrics, logs) via OTLP
- Docker Compose deployment with collector
- Trace ID correlation in database records
- Token usage tracking and cost calculation
#### Quick Start
```bash showLineNumbers title="Terminal"
cd rust/ai-report-generator
# Set your API key
export OPENAI_API_KEY=sk-...
# Start all services
docker compose up -d
# Generate a report
curl -X POST http://localhost:8080/api/reports \
-H "Content-Type: application/json" \
-d '{
"indicators": ["GDP", "UNRATE", "CPIAUCSL"],
"start_date": "2020-01-01",
"end_date": "2023-12-01"
}'
```
#### Project Structure
```text showLineNumbers title="Project layout"
src/
├── main.rs # Server, router, HTTP spans
├── config.rs # Environment configuration
├── telemetry/
│ ├── init.rs # OTEL SDK initialization
│ └── metrics.rs # GenAI + HTTP + domain metrics
├── llm/
│ ├── mod.rs # Provider trait, request/response
│ ├── client.rs # Retry, fallback, GenAI spans
│ ├── openai.rs # OpenAI/Google/Ollama provider
│ ├── anthropic.rs # Anthropic provider
│ └── pricing.rs # Cost calculation
├── pipeline/
│ ├── orchestrator.rs # Pipeline coordinator
│ ├── retrieve.rs # Stage 1: DB queries
│ ├── analyze.rs # Stage 2: LLM analysis
│ ├── generate.rs # Stage 3: LLM narrative
│ └── format.rs # Stage 4: Report assembly
├── db/ # Database queries
├── routes/ # HTTP handlers
└── error.rs # Error types
```
### References
- [OpenTelemetry Rust SDK](https://opentelemetry.io/docs/languages/rust/)
- [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/)
- [tracing-opentelemetry crate](https://docs.rs/tracing-opentelemetry/)
- [async-openai crate](https://docs.rs/async-openai/)
### Related Guides
- [LLM Observability (Python)](../llm-observability) - Python equivalent of this
guide with auto-instrumentation patterns
- [Axum Instrumentation](../../instrument/apps/auto-instrumentation/axum.md) —
General Axum APM setup
- [Rust Custom Instrumentation](../../instrument/apps/custom-instrumentation/rust.md)
- Manual OpenTelemetry SDK for Rust
- [Vercel AI SDK](../../instrument/apps/auto-instrumentation/vercel-ai-sdk.md) —
TypeScript AI pipeline monitoring
---
## Spring AI OpenTelemetry Instrumentation - Java LLM Tracing & Metrics Guide
import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem';
## Java AI Observability
Implement unified observability for Java AI applications using OpenTelemetry and
Spring AI. This guide shows you how to instrument a conversational AI customer
support agent with three-layer instrumentation unique to the Java ecosystem —
the OpenTelemetry Java Agent for zero-code auto-capture of HTTP and database
spans, Spring AI's built-in Micrometer observations bridged to OpenTelemetry for
ChatModel and VectorStore spans, and manual OpenTelemetry API calls for GenAI
semantic convention spans with token, cost, and pipeline context. The result is
a single correlated trace that connects every layer of your AI application, from
HTTP entry through intent classification, RAG retrieval, tool calling, and LLM
completion.
Java AI applications have a distinct observability advantage over other
languages. An LLM call is not just an HTTP request - it carries semantic
meaning: which model was used, how many tokens were consumed, what it cost,
whether the response was a fallback from another provider. Java uniquely offers
three composable instrumentation layers: the OpenTelemetry Java Agent
(`-javaagent` flag) provides zero-code auto-instrumentation for HTTP
server/client spans, JDBC queries, and R2DBC connections. Spring AI emits
Micrometer observations for ChatModel and VectorStore operations, which the
`micrometer-tracing-bridge-otel` dependency bridges directly into OpenTelemetry.
And the manual OpenTelemetry API (`GlobalOpenTelemetry.getTracer()` /
`getMeter()`) adds GenAI semantic convention attributes and custom metrics that
neither auto layer provides. All three layers share the same trace context,
producing a unified trace with zero instrumentation gaps.
Whether you are building AI support systems with Spring AI, integrating OpenAI,
Anthropic, or Ollama as LLM providers, or running local models for development,
this guide provides production-ready patterns for unified AI observability in
Java. You will learn how to set up three-pillar telemetry (traces, metrics,
logs), create GenAI spans with the correct semantic conventions, define 11
standard metrics covering token usage, cost, duration, errors, retries, and
fallbacks, instrument a 6-stage AI support pipeline with parent-child spans,
implement Spring AI tool calling with `@Tool` methods, set up RAG with pgvector
and track retrieval quality metrics, implement multi-provider LLM support with
retry and fallback observability, track domain-specific business metrics like
conversation duration and escalation rates, and deploy with Docker Compose and
the OpenTelemetry Collector - all visible in a single trace on base14 Scout.
:::info Cross-references
For general LLM observability patterns applicable to any language, see the
[LLM Observability guide](../llm-observability). This guide focuses specifically
on Java and Spring AI integration patterns. For Rust AI applications, see the
[Rust LLM Observability guide](../rust-llm-observability). For non-AI Java web
application instrumentation, check if a Spring Boot auto-instrumentation guide
is available under
[auto-instrumentation](../../../instrument/apps/auto-instrumentation/).
:::
:::tip TL;DR
Add the OpenTelemetry Java Agent (`-javaagent`), Spring AI's Micrometer bridge
(`micrometer-tracing-bridge-otel`), and manual `GlobalOpenTelemetry` calls to
get unified traces across HTTP, database, LLM, and pipeline layers. This guide
covers Spring Boot 4.0.3 + Spring AI 2.0 with OpenAI, Anthropic, and Ollama.
:::
:::note Running this in production
Storing and querying these traces at production volume is what base14 Scout
does.
[Check out Scout LLM Observability](https://base14.io/scout/llm-observability).
:::
### Who This Guide Is For
This documentation is designed for:
- **Java/Spring AI developers**: building LLM-powered features (customer
support, chatbots, AI assistants) and needing visibility into model
performance, cost, and pipeline throughput
- **Backend developers**: adding AI capabilities to existing Spring Boot
applications and wanting unified tracing across HTTP, database, and LLM layers
- **Platform teams**: standardizing observability across Java AI services and
traditional microservices using OpenTelemetry
- **Engineering teams**: migrating from DataDog, New Relic, or other commercial
APM solutions to open-standard OpenTelemetry
- **DevOps engineers**: deploying Java AI applications with production
monitoring, cost alerting, and pipeline health tracking
### Overview
This guide demonstrates how to:
- Set up three-layer OpenTelemetry for a Java AI application (Java Agent +
Spring AI + manual OTel API)
- Create custom LLM spans following OpenTelemetry GenAI semantic conventions
- Define GenAI metrics for token usage, cost, duration, errors, retries, and
fallbacks
- Instrument a 6-stage AI support pipeline with parent-child spans
- Implement Spring AI tool calling with observability (`@Tool` methods)
- Set up RAG with pgvector and track retrieval quality metrics
- Implement multi-provider LLM support (OpenAI, Anthropic, Ollama) with retry
and fallback observability
- Track domain-specific business metrics (conversation duration, escalation
rates, tool success)
- Deploy with Docker Compose and the OpenTelemetry Collector
### Prerequisites
Before starting, ensure you have:
- **Java 25+** installed (21+ minimum)
- **Spring Boot 4.0.3+**
- **Spring AI 2.0+** (BOM `2.0.0-M2` or later)
- **Scout Collector** configured and accessible from your application - see
[Docker Compose Setup](../../instrument/collector-setup/docker-compose-example.md)
for local development
- **Basic understanding of OpenTelemetry concepts** (traces, spans, attributes)
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version |
| ------------------------ | --------------- | ------------------- |
| Java | 21 | 25+ |
| Spring Boot | 3.4+ | 4.0.3+ |
| Spring AI | 1.0.0 | 2.0.0-M2+ |
| OpenTelemetry Java Agent | 2.0.0 | 2.25+ |
| OpenTelemetry API | 1.40.0 | 1.52+ |
| PostgreSQL (pgvector) | 15 | 18+ |
### The Unified Trace
The core value of OpenTelemetry for Java AI applications is the **unified
trace** - a single trace ID that connects every instrumentation layer, from HTTP
entry through pipeline orchestration to LLM completions and database queries.
Here is what a trace looks like for a customer support chat request that spans
all three layers:
```text showLineNumbers title="Single trace spanning all three instrumentation layers"
POST /api/chat 3.8s [Layer 1: Java Agent]
├─ support_conversation 3.7s [Layer 3: Manual OTel]
│ ├─ classify_intent 0.4s [Layer 3: Manual OTel]
│ │ └─ gen_ai.chat gpt-4.1-mini 0.3s [Layer 3: Manual OTel]
│ │ └─ ChatModel 0.3s [Layer 2: Spring AI]
│ │ └─ HTTP POST api.openai.com 0.3s [Layer 1: Java Agent]
│ ├─ rag_retrieval 0.1s [Layer 3: Manual OTel]
│ │ └─ VectorStore 0.1s [Layer 2: Spring AI]
│ │ └─ db.query pgvector 15ms [Layer 1: Java Agent]
│ ├─ generate_response 3.1s [Layer 3: Manual OTel]
│ │ └─ gen_ai.chat gpt-4.1 3.0s [Layer 3: Manual OTel]
│ │ └─ ChatModel 3.0s [Layer 2: Spring AI]
│ │ ├─ HTTP POST api.openai.com 1.2s [Layer 1: Java Agent]
│ │ ├─ @Tool getOrderStatus 8ms [Layer 2: Spring AI]
│ │ │ └─ db.query orders 5ms [Layer 1: Java Agent]
│ │ └─ HTTP POST api.openai.com 1.7s [Layer 1: Java Agent]
│ └─ escalation_check 1ms [Layer 3: Manual OTel]
```
Three instrumentation layers work together in a single trace:
- **Layer 1 - Java Agent** (zero-code): Captures the outermost HTTP server span,
outbound HTTP client spans to LLM APIs, and JDBC database query spans - all
without any code changes
- **Layer 2 - Spring AI** (Micrometer bridge): Adds ChatModel call spans,
VectorStore query spans, and `@Tool` method execution spans as children of the
current trace context
- **Layer 3 - Manual OTel API**: Adds GenAI semantic convention attributes
(model, tokens, cost), pipeline orchestration spans (`support_conversation`,
`classify_intent`, `rag_retrieval`), and custom metrics
The Java Agent provides context propagation that ties everything together.
Spring AI observations nest inside that context. Manual spans add the
GenAI-specific metadata that neither auto layer provides. The result is a trace
where you can see that a 3.8-second customer support response spent 0.4 seconds
on intent classification with `gpt-4.1-mini`, 0.1 seconds on RAG retrieval, and
3.1 seconds on response generation with `gpt-4.1` including a tool call to look
up order status.
### Three-Layer Architecture
Java AI applications benefit from a three-layer instrumentation approach that no
other language ecosystem matches. Each layer captures telemetry at a different
level of abstraction, and all three compose into unified traces through shared
OpenTelemetry context propagation.
| Layer | Source | What It Captures |
| ------------------ | ------------------------------------------------------------ | ----------------------------------------------------------------------- |
| 1. Java Agent | `opentelemetry-javaagent.jar` (zero-code) | HTTP server/client spans, JDBC/R2DBC queries, Spring WebFlux |
| 2. Spring AI | Micrometer observations via `micrometer-tracing-bridge-otel` | ChatModel calls, VectorStore operations, tool execution |
| 3. Manual OTel API | `GlobalOpenTelemetry.getTracer()` / `getMeter()` | GenAI semantic convention spans, custom metrics, pipeline orchestration |
#### Layer 1: Java Agent (Zero-Code Auto-Instrumentation)
The OpenTelemetry Java Agent attaches to the JVM via the `-javaagent` flag and
automatically instruments HTTP, database, and messaging frameworks with zero
code changes. It provides the outermost spans in every trace and handles context
propagation between all layers.
What it captures:
- **HTTP server spans** - Spring WebFlux incoming requests with method, path,
status code, and latency
- **HTTP client spans** - outbound calls to LLM provider APIs (OpenAI,
Anthropic) with URL, status, and duration
- **JDBC spans** - tool database queries (order lookups, product searches) with
SQL statement and execution time
- **R2DBC spans** - reactive database access for conversation persistence
Configuration is entirely via environment variables. No code changes or
dependency additions are needed - the agent injects instrumentation at the
bytecode level.
The Dockerfile downloads the agent JAR and attaches it at startup:
```dockerfile showLineNumbers title="Dockerfile"
FROM gradle:9.2.1-jdk25 AS builder
WORKDIR /app
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
COPY src ./src
RUN gradle build -x test --no-daemon
FROM eclipse-temurin:25-jre
WORKDIR /app
ADD https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.25.0/opentelemetry-javaagent.jar /app/opentelemetry-javaagent.jar
COPY --from=shared pricing.json /app/pricing.json
COPY --from=builder /app/build/libs/ai-customer-support-0.0.1-SNAPSHOT.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", \
"-javaagent:/app/opentelemetry-javaagent.jar", \
"-jar", "/app/app.jar"]
```
The agent is configured through environment variables in the Docker Compose
service definition:
```yaml showLineNumbers title="compose.yml (agent environment variables)"
environment:
OTEL_SERVICE_NAME: ai-customer-support
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
OTEL_TRACES_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_LOGS_EXPORTER: otlp
OTEL_INSTRUMENTATION_COMMON_DEFAULT_ENABLED: "true"
```
#### Layer 2: Spring AI Observations (Micrometer Bridge)
Spring AI emits Micrometer observations for ChatModel and VectorStore
operations. The `micrometer-tracing-bridge-otel` dependency bridges these
observations directly into OpenTelemetry, so they appear as child spans in the
same trace context established by the Java Agent.
What it captures:
- **ChatModel call spans** - model name, provider, and call duration for every
`chatModel.call()` invocation
- **VectorStore query spans** - similarity search operations against pgvector
- **Tool execution spans** - `@Tool` method invocations triggered by the LLM's
tool-calling protocol
Configuration is in `application.yml`. The `management.otlp` section configures
the OTLP export endpoints, `management.tracing` sets the sampling rate, and
`spring.ai.chat.observations` controls whether prompt/completion content is
included in spans:
```yaml showLineNumbers title="src/main/resources/application.yml"
management:
endpoints:
web:
exposure:
include: health,info,metrics
otlp:
tracing:
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/traces
metrics:
export:
url: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/metrics
tracing:
sampling:
probability: 1.0
spring:
ai:
chat:
observations:
include-input: false
include-output: false
```
Setting `include-input` and `include-output` to `false` prevents prompt and
completion content from being recorded in Micrometer observation spans. This is
a production safety default - prompt content may contain PII. If you need
content capture for debugging, the manual OTel layer (Layer 3) provides
PII-scrubbed content recording controlled by the
`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` environment variable.
#### Layer 3: Manual OTel API (GenAI Semantic Conventions)
Direct use of `GlobalOpenTelemetry.getTracer()` and
`GlobalOpenTelemetry.getMeter()` provides the GenAI-specific telemetry that
neither the Java Agent nor Spring AI observations capture. This layer adds
OpenTelemetry GenAI semantic convention attributes to LLM spans, defines custom
metrics for token usage and cost tracking, and creates pipeline orchestration
spans that give business context to traces.
What it captures:
- **`gen_ai.chat {model}` spans** with full GenAI semantic convention attributes
(`gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.request.model`,
`gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`,
`gen_ai.usage.cost_usd`, `gen_ai.response.finish_reasons`)
- **Custom GenAI metrics** - token usage histograms, cost counters, duration
histograms, error counters, retry counters, fallback counters
- **Domain-specific pipeline spans** - `support_conversation`,
`classify_intent`, `rag_retrieval`, `generate_response`, `escalation_check`
Why this layer is needed: Spring AI's Micrometer observations record that a
ChatModel call happened and how long it took, but they do not include GenAI
semantic conventions like token counts, cost, error classification, or the
specific model that responded (which may differ from the requested model after
fallback). The manual OTel API adds this layer.
The tracer and meter are initialized from `GlobalOpenTelemetry`, which the Java
Agent populates at startup:
```java showLineNumbers title="Tracer and Meter initialization pattern"
private static final Tracer tracer =
GlobalOpenTelemetry.getTracer("ai-customer-support");
private static final Meter meter =
GlobalOpenTelemetry.getMeter("ai-customer-support");
```
#### How the Layers Compose
The three layers compose through OpenTelemetry's context propagation. The Java
Agent creates the outermost HTTP server span and propagates the trace context to
all child operations. When Spring AI's Micrometer-bridged observations start,
they pick up the current trace context and create child spans. When manual
`tracer.spanBuilder()` calls start, they also inherit the current context. The
result is a single trace where Layer 1 provides the HTTP and database frame,
Layer 2 adds AI framework observations, and Layer 3 adds GenAI semantic
convention attributes and custom metrics. No explicit context passing is needed
between layers - `Span.current()` and `span.makeCurrent()` handle the
composition automatically.
### Installation
Add the following dependencies to your `build.gradle`. The project uses Spring
Boot 4.0.3 with the Spring AI BOM for version management:
```groovy showLineNumbers title="build.gradle"
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.3'
id 'io.spring.dependency-management' version '1.1.7'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:2.0.0-M2"
}
}
dependencies {
// Web (reactive)
implementation 'org.springframework.boot:spring-boot-starter-webflux'
// Observability
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-tracing-bridge-otel'
implementation 'io.opentelemetry:opentelemetry-exporter-otlp'
implementation 'io.opentelemetry:opentelemetry-api'
// Spring AI - LLM providers
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-starter-model-anthropic'
implementation 'org.springframework.ai:spring-ai-starter-model-ollama'
// Spring AI - pgvector RAG
implementation 'org.springframework.ai:spring-ai-starter-vector-store-pgvector'
// Database (reactive + JDBC for pgvector)
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.postgresql:r2dbc-postgresql'
implementation 'org.postgresql:postgresql'
// JSON
implementation 'com.fasterxml.jackson.core:jackson-databind'
}
```
Key dependency groups:
- **Observability bridge**: `micrometer-tracing-bridge-otel` connects Spring
AI's Micrometer observations to OpenTelemetry. `opentelemetry-exporter-otlp`
sends telemetry to the Collector. `opentelemetry-api` provides the manual
tracer/meter API for Layer 3.
- **Spring AI providers**: Each `spring-ai-starter-model-{provider}` dependency
brings in the ChatModel implementation for that provider. You can include
multiple providers for fallback support.
- **Spring AI BOM**: The `spring-ai-bom:2.0.0-M2` import manages version
alignment across all Spring AI dependencies.
- **Dual database drivers**: R2DBC for reactive conversation persistence, JDBC
for pgvector RAG and Spring AI tool methods (which use `JdbcTemplate`).
The OpenTelemetry Java Agent is not a Gradle dependency - it is downloaded
separately and attached via the `-javaagent` JVM flag. The Dockerfile handles
this automatically by downloading the agent JAR from the
[OpenTelemetry Java Agent releases](https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases)
page. For local development without Docker, download the JAR manually and pass
it as a JVM argument:
```bash showLineNumbers title="Local development agent setup"
curl -L -o opentelemetry-javaagent.jar \
https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.25.0/opentelemetry-javaagent.jar
java -javaagent:opentelemetry-javaagent.jar \
-jar build/libs/ai-customer-support-0.0.1-SNAPSHOT.jar
```
### Spring AI OpenTelemetry Configuration
This section covers every configuration surface in the application: Spring Boot
OTLP export settings, provider-specific Spring AI configuration, application
properties for LLM routing, and the provider resolution logic that maps
configuration strings to Spring AI bean names.
#### Spring Boot OpenTelemetry Configuration
The `management:` block in `application.yml` configures how Spring Boot exports
telemetry to the OpenTelemetry Collector. This was introduced in the
[Three-Layer Architecture](#layer-2-spring-ai-observations-micrometer-bridge)
section - here is the full breakdown of each setting:
```yaml showLineNumbers title="src/main/resources/application.yml"
management:
endpoints:
web:
exposure:
include: health,info,metrics
otlp:
tracing:
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/traces
metrics:
export:
url: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}/v1/metrics
tracing:
sampling:
probability: 1.0
```
- **`management.endpoints.web.exposure.include`** - Exposes Actuator endpoints
for health checks, application info, and Micrometer metrics. These are useful
for Kubernetes liveness/readiness probes and debugging metric registration.
- **`management.otlp.tracing.endpoint`** - The OTLP HTTP endpoint for trace
export. Defaults to `http://localhost:4318/v1/traces` for local development.
In Docker Compose, the `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable
overrides this to point at the Collector container.
- **`management.otlp.metrics.export.url`** - The OTLP HTTP endpoint for metric
export. Uses the same base URL as traces but with the `/v1/metrics` path.
- **`management.tracing.sampling.probability`** - Controls the sampling rate.
`1.0` means 100% of traces are sampled - appropriate for development and
low-traffic production. For high-traffic services, reduce this to `0.1` (10%)
or use the Collector's tail-sampling processor for more intelligent sampling.
#### Provider Configuration
Spring AI auto-configures a ChatModel bean for each provider that has a starter
dependency on the classpath. Each provider requires its own configuration block
under `spring.ai`. The application supports OpenAI, Anthropic, and Ollama - you
configure the one you want to use and set `app.llm.provider` to select it at
runtime.
**OpenAI**
```yaml showLineNumbers title="src/main/resources/application.yml"
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY:}
chat:
options:
model: ${LLM_MODEL_CAPABLE:gpt-4.1}
temperature: ${DEFAULT_TEMPERATURE:0.3}
embedding:
options:
model: ${EMBEDDING_MODEL:text-embedding-3-small}
```
OpenAI is the default provider. The `api-key` is read from the `OPENAI_API_KEY`
environment variable. The `chat.options.model` sets the default model for
ChatModel calls - this can be overridden per-request via
`ChatOptions.builder()`. The `embedding.options.model` configures the embedding
model used by the pgvector VectorStore for RAG retrieval.
**Anthropic**
```yaml showLineNumbers title="src/main/resources/application.yml"
spring:
ai:
anthropic:
api-key: ${ANTHROPIC_API_KEY:}
chat:
options:
model: ${LLM_MODEL_CAPABLE:claude-sonnet-4-6}
```
Anthropic is configured as either the primary or fallback provider. Note that
Anthropic does not provide an embedding model through Spring AI, so the
application uses OpenAI embeddings for RAG even when Anthropic is the primary
chat provider.
**Ollama**
```yaml showLineNumbers title="src/main/resources/application-ollama.yml"
spring:
autoconfigure:
exclude:
- org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfiguration
- org.springframework.ai.model.openai.autoconfigure.OpenAiEmbeddingAutoConfiguration
- org.springframework.ai.model.openai.autoconfigure.OpenAiImageAutoConfiguration
- org.springframework.ai.model.openai.autoconfigure.OpenAiAudioSpeechAutoConfiguration
- org.springframework.ai.model.openai.autoconfigure.OpenAiAudioTranscriptionAutoConfiguration
- org.springframework.ai.model.openai.autoconfigure.OpenAiModerationAutoConfiguration
- org.springframework.ai.model.anthropic.autoconfigure.AnthropicChatAutoConfiguration
ai:
ollama:
embedding:
options:
model: embeddinggemma
vectorstore:
pgvector:
dimensions: 768
app:
llm:
provider: ollama
model-capable: qwen3:latest
model-fast: gemma3:4b
fallback-provider: ollama
fallback-model: gemma3:12b
```
The Ollama profile (`application-ollama.yml`) disables OpenAI and Anthropic
auto-configuration since those providers are not needed when running locally. It
also switches the embedding model to `embeddinggemma` and reduces pgvector
dimensions to 768 to match the local embedding model's output size. Activate
this profile with `SPRING_PROFILES_ACTIVE=ollama`.
#### Application Properties
The `AppConfig` record maps the `app.llm` configuration section to a type-safe
Java record using Spring Boot's `@ConfigurationProperties`:
```java showLineNumbers title="src/main/java/com/example/support/config/AppConfig.java"
@ConfigurationProperties(prefix = "app.llm")
public record AppConfig(
String provider,
String modelCapable,
String modelFast,
String fallbackProvider,
String fallbackModel,
int maxTokens,
double temperature
) {}
```
These properties control LLM routing at the application level. The corresponding
YAML configuration provides defaults that environment variables can override:
```yaml showLineNumbers title="src/main/resources/application.yml"
app:
llm:
provider: ${LLM_PROVIDER:openai}
model-capable: ${LLM_MODEL_CAPABLE:gpt-4.1}
model-fast: ${LLM_MODEL_FAST:gpt-4.1-mini}
fallback-provider: ${FALLBACK_PROVIDER:anthropic}
fallback-model: ${FALLBACK_MODEL:claude-haiku-4-5-20251001}
max-tokens: ${DEFAULT_MAX_TOKENS:1024}
temperature: ${DEFAULT_TEMPERATURE:0.3}
```
- **`provider`** - The primary LLM provider (`openai`, `anthropic`, or
`ollama`). Determines which ChatModel bean is used for all LLM calls.
- **`model-capable`** - The high-quality model used for response generation and
complex tasks. Maps to `config.modelCapable()` in Java.
- **`model-fast`** - The faster, cheaper model used for intent classification
and simple tasks. Maps to `config.modelFast()` in Java.
- **`fallback-provider`** / **`fallback-model`** - The provider and model to use
when the primary provider fails after all retries are exhausted.
- **`max-tokens`** / **`temperature`** - Default generation parameters applied
to every LLM call via `ChatOptions`.
#### Provider Resolution
The `LlmConfig` class resolves the provider string from configuration to the
correct Spring AI `ChatModel` bean. Spring AI auto-configures a ChatModel bean
for each provider on the classpath - `LlmConfig.resolveChatModel()` maps the
provider name to the Spring-managed bean name:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmConfig.java"
@Configuration
public class LlmConfig {
public static final Map PROVIDER_SERVERS = Map.of(
"openai", "api.openai.com",
"anthropic", "api.anthropic.com",
"google", "generativelanguage.googleapis.com",
"ollama", "localhost"
);
public static final Map PROVIDER_PORTS = Map.of(
"openai", 443,
"anthropic", 443,
"google", 443,
"ollama", 11434
);
public static ChatModel resolveChatModel(
String provider, Map chatModels
) {
String beanName = switch (provider) {
case "openai" -> "openAiChatModel";
case "anthropic" -> "anthropicChatModel";
case "ollama" -> "ollamaChatModel";
default -> throw new IllegalArgumentException(
"Unknown LLM provider: " + provider);
};
var model = chatModels.get(beanName);
if (model == null) {
throw new IllegalStateException(
"ChatModel bean '" + beanName + "' not found. "
+ "Available: " + chatModels.keySet());
}
return model;
}
}
```
The `PROVIDER_SERVERS` and `PROVIDER_PORTS` maps provide OpenTelemetry
`server.address` and `server.port` span attributes for each provider. These are
standard OTel attributes that help correlate LLM spans with network-level
telemetry. The `resolveChatModel()` method uses a switch expression to map the
provider string (`"openai"`, `"anthropic"`, `"ollama"`) to the Spring AI bean
name (`"openAiChatModel"`, `"anthropicChatModel"`, `"ollamaChatModel"`). If the
provider string does not match any known provider, it throws an
`IllegalArgumentException`. If the bean exists but was not auto-configured (for
example, missing API key), it throws an `IllegalStateException` listing the
available beans for debugging.
#### Environment Variables
The application uses environment variables for all sensitive and
deployment-specific configuration. Here is the complete set:
```bash showLineNumbers title=".env.example"
APP_PORT=8080
DB_HOST=localhost
DB_PORT=5432
DB_NAME=support
DB_USER=postgres
DB_PASSWORD=postgres
LLM_PROVIDER=openai
LLM_MODEL_CAPABLE=gpt-4.1
LLM_MODEL_FAST=gpt-4.1-mini
FALLBACK_PROVIDER=anthropic
FALLBACK_MODEL=claude-haiku-4-5-20251001
OLLAMA_BASE_URL=http://localhost:11434
DEFAULT_TEMPERATURE=0.3
DEFAULT_MAX_TOKENS=1024
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GOOGLE_API_KEY=
OTEL_SERVICE_NAME=ai-customer-support
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
```
The `OTEL_SERVICE_NAME` and `OTEL_EXPORTER_OTLP_ENDPOINT` variables are used by
both the Java Agent (Layer 1) and Spring Boot Actuator (Layer 2). The Java Agent
reads them directly; Spring Boot references them via `${...}` placeholders in
`application.yml`. This means a single environment variable controls both
layers.
### Custom LLM Instrumentation
This section covers the core GenAI span creation in `LlmService` - the Layer 3
manual instrumentation that adds OpenTelemetry GenAI semantic convention
attributes, error classification, and content capture to every LLM call.
#### The GenAI Span
The `generateOnce()` method creates a `gen_ai.chat {model}` span for each LLM
call. This span follows the
[OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/)
and carries all the attributes needed to understand model usage, performance,
and cost.
Here is the full method with annotations for each section:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
private LlmResponse generateOnce(
ChatModel chatModel, String providerName, String model,
String systemPrompt, String userPrompt, String stage,
List toolCallbacks
) {
String spanName = "gen_ai.chat " + model;
long start = System.nanoTime();
Span span = tracer.spanBuilder(spanName)
.setAttribute("gen_ai.operation.name", "chat")
.setAttribute("gen_ai.provider.name", providerName)
.setAttribute("gen_ai.request.model", model)
.setAttribute("server.address",
LlmConfig.PROVIDER_SERVERS.getOrDefault(providerName, "unknown"))
.setAttribute("server.port",
(long) LlmConfig.PROVIDER_PORTS.getOrDefault(providerName, 443))
.setAttribute("gen_ai.request.temperature", config.temperature())
.setAttribute("gen_ai.request.max_tokens", (long) config.maxTokens())
.startSpan();
if (stage != null && !stage.isEmpty()) {
span.setAttribute("support.stage", stage);
}
try (Scope ignored = span.makeCurrent()) {
// ... content capture, prompt building, ChatModel call ...
var generation = response.getResult();
var metadata = generation.getMetadata();
var usage = response.getMetadata().getUsage();
String content = generation.getOutput().getText();
int inputTokens = usage != null ? (int) usage.getPromptTokens() : 0;
int outputTokens = usage != null
? (int) usage.getCompletionTokens() : 0;
String responseModel = response.getMetadata().getModel() != null
? response.getMetadata().getModel() : model;
String finishReason = metadata.getFinishReason() != null
? metadata.getFinishReason() : "";
double costUsd = pricing.calculateCost(
responseModel, inputTokens, outputTokens);
double duration = (System.nanoTime() - start) / 1_000_000_000.0;
span.setAttribute("gen_ai.response.model", responseModel);
span.setAttribute("gen_ai.usage.input_tokens", (long) inputTokens);
span.setAttribute("gen_ai.usage.output_tokens", (long) outputTokens);
span.setAttribute("gen_ai.usage.cost_usd", costUsd);
if (!finishReason.isEmpty()) {
span.setAttribute("gen_ai.response.finish_reasons", finishReason);
}
// ... metric recording, return ...
} catch (Exception e) {
// ... error handling ...
} finally {
span.end();
}
}
```
The span is structured in three phases:
**Request attributes** (set before `startSpan()`): These describe what the
application asked for - the operation type, provider, model, server address,
temperature, and max tokens. Setting them on the builder ensures they are
available from the start of the span, which matters for streaming scenarios
where the span may be visible before the response arrives.
**Response attributes** (set after `chatModel.call()`): These describe what the
LLM returned - the actual model that responded (which may differ from the
requested model after provider routing), token counts, cost, and finish reason.
The `gen_ai.response.model` attribute is particularly important for fallback
scenarios where the response model differs from `gen_ai.request.model`.
**Custom attributes**: The `support.stage` attribute is a domain-specific
addition that links the LLM span to the pipeline stage that triggered it
(`classify_intent`, `generate_response`, etc.). This is not part of the GenAI
semantic conventions but is valuable for filtering and grouping spans by
business context.
#### Error Handling on Spans
When an LLM call fails, the span records both the OpenTelemetry error status and
a classified error type. The `catch` block in `generateOnce()` sets the span
status to `ERROR` and adds an `error.type` attribute with a categorized error
string:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
} catch (Exception e) {
span.setStatus(StatusCode.ERROR, e.getMessage());
span.setAttribute("error.type", classifyError(e));
errorCounter.add(1, Attributes.of(
AttributeKey.stringKey("gen_ai.provider.name"), providerName,
AttributeKey.stringKey("gen_ai.request.model"), model,
AttributeKey.stringKey("error.type"), classifyError(e)
));
throw e;
} finally {
span.end();
}
```
The `classifyError()` method maps exception messages to standardized error
categories. This avoids high-cardinality error strings in your telemetry backend
and enables meaningful alerting on error type:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
static String classifyError(Exception e) {
if (e == null) return "unknown_error";
String msg = e.getMessage() != null ? e.getMessage().toLowerCase() : "";
if (msg.contains("rate limit") || msg.contains("429"))
return "rate_limit";
if (msg.contains("timeout") || msg.contains("timed out")
|| msg.contains("deadline"))
return "timeout";
if (msg.contains("401") || msg.contains("403")
|| msg.contains("auth") || msg.contains("api key"))
return "auth_error";
if (msg.contains("400") || msg.contains("422")
|| msg.contains("invalid"))
return "invalid_request";
if (msg.contains("500") || msg.contains("502")
|| msg.contains("503") || msg.contains("server"))
return "server_error";
if (msg.contains("connect") || msg.contains("dns")
|| msg.contains("network") || msg.contains("reset"))
return "network_error";
return "unknown_error";
}
```
The classification produces one of seven values: `rate_limit`, `timeout`,
`auth_error`, `invalid_request`, `server_error`, `network_error`, or
`unknown_error`. These categories are intentionally coarse - they produce
low-cardinality metric labels that work well with alerting rules. For example,
you can alert on `error.type = rate_limit` to detect when your API key is
hitting quota limits, or on `error.type = auth_error` to detect expired or
revoked credentials.
#### Span Events (Content Capture)
The `generateOnce()` method optionally records prompt and completion content as
span events. Content capture is gated behind the
`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` environment variable and is
disabled by default:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
this.captureContent = "true".equalsIgnoreCase(
System.getenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"));
```
When enabled, three span events are recorded on each LLM call:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
try (Scope ignored = span.makeCurrent()) {
if (captureContent) {
span.addEvent("gen_ai.user.message", Attributes.of(
AttributeKey.stringKey("gen_ai.prompt"),
truncate(piiFilter.scrub(userPrompt), 1000)
));
if (systemPrompt != null && !systemPrompt.isEmpty()) {
span.addEvent("gen_ai.user.message", Attributes.of(
AttributeKey.stringKey("gen_ai.system_instructions"),
truncate(systemPrompt, 500)
));
}
}
var prompt = buildPrompt(systemPrompt, userPrompt, model, toolCallbacks);
ChatResponse response = chatModel.call(prompt);
// ... response processing ...
if (captureContent) {
span.addEvent("gen_ai.assistant.message", Attributes.of(
AttributeKey.stringKey("gen_ai.completion"),
truncate(piiFilter.scrub(content), 2000)
));
}
// ...
}
```
Each span event captures a different part of the conversation:
| Event Name | Attribute | Content | Max Length |
| -------------------------- | ---------------------------- | ------------- | ---------- |
| `gen_ai.user.message` | `gen_ai.prompt` | User input | 1000 chars |
| `gen_ai.user.message` | `gen_ai.system_instructions` | System prompt | 500 chars |
| `gen_ai.assistant.message` | `gen_ai.completion` | LLM response | 2000 chars |
Two safety measures protect sensitive data in span events:
1. **PII filtering** - User input and LLM responses pass through
`piiFilter.scrub()` before recording. This replaces patterns like email
addresses, phone numbers, and credit card numbers with redaction markers. The
system prompt is not PII-filtered because it is developer-authored content
that should not contain user data.
2. **Truncation** - All content is truncated to prevent span events from
becoming excessively large. User prompts are capped at 1000 characters,
system prompts at 500, and completions at 2000. These limits balance
debuggability with storage costs.
Content capture is off by default for good reason: prompt content may contain
PII, proprietary data, or information subject to compliance requirements (GDPR,
HIPAA, SOC 2). Enable it only in environments where content inspection is
appropriate - development, staging, or production with explicit data handling
agreements. Set the environment variable in your deployment:
```bash showLineNumbers
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
```
#### GenAI Semantic Conventions Reference
The following table summarizes all GenAI attributes set on `gen_ai.chat` spans.
These follow the
[OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/):
| Attribute | Example Value | Source |
| -------------------------------- | ------------------- | ----------------------------------- |
| `gen_ai.operation.name` | `"chat"` | Hardcoded |
| `gen_ai.provider.name` | `"openai"` | `config.provider()` |
| `gen_ai.request.model` | `"gpt-4.1"` | Method parameter |
| `gen_ai.request.temperature` | `0.3` | `config.temperature()` |
| `gen_ai.request.max_tokens` | `1024` | `config.maxTokens()` |
| `gen_ai.response.model` | `"gpt-4.1"` | `response.getMetadata().getModel()` |
| `gen_ai.usage.input_tokens` | `150` | `usage.getPromptTokens()` |
| `gen_ai.usage.output_tokens` | `380` | `usage.getCompletionTokens()` |
| `gen_ai.usage.cost_usd` | `0.00234` | `pricing.calculateCost()` |
| `gen_ai.response.finish_reasons` | `"stop"` | `metadata.getFinishReason()` |
| `server.address` | `"api.openai.com"` | `LlmConfig.PROVIDER_SERVERS` |
| `server.port` | `443` | `LlmConfig.PROVIDER_PORTS` |
| `support.stage` | `"classify_intent"` | Method parameter (custom) |
| `error.type` | `"rate_limit"` | `classifyError()` (on error only) |
### Token and Cost Tracking
Token usage and cost are central to LLM observability. Unlike traditional API
calls where cost is roughly proportional to request count, LLM costs scale with
token consumption - a single request can cost 100x more than another depending
on prompt length and response size. This section covers how the application
defines GenAI metrics, calculates cost from a pricing table, and records both
metrics and span attributes for every LLM call.
#### GenAI Metrics Definition
Six metrics are defined in the `LlmService` constructor using the OpenTelemetry
`Meter` API. These metrics follow the naming patterns from the OpenTelemetry
GenAI semantic conventions:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
this.tracer = GlobalOpenTelemetry.getTracer("ai-customer-support");
Meter meter = GlobalOpenTelemetry.getMeter("ai-customer-support");
this.tokenUsage = meter.histogramBuilder("gen_ai.client.token.usage")
.setUnit("{token}").build();
this.operationDuration = meter.histogramBuilder("gen_ai.client.operation.duration")
.setUnit("s").build();
this.costCounter = meter.counterBuilder("gen_ai.client.cost")
.ofDoubles().setUnit("usd").build();
this.retryCounter = meter.counterBuilder("gen_ai.client.retry.count")
.build();
this.fallbackCounter = meter.counterBuilder("gen_ai.client.fallback.count")
.build();
this.errorCounter = meter.counterBuilder("gen_ai.client.error.count")
.build();
```
Each metric serves a specific observability purpose:
- **`gen_ai.client.token.usage`** - Histogram of token counts per LLM call.
Recorded twice per successful call: once with `gen_ai.token.type = "input"`
and once with `gen_ai.token.type = "output"`. Histograms capture the
distribution, so you can compute p50, p95, and p99 token usage for capacity
planning and anomaly detection.
- **`gen_ai.client.operation.duration`** - Histogram of LLM call duration in
seconds. Captures end-to-end latency including network round-trip, model
inference, and any tool-calling loops. Use this to track model performance
degradation over time.
- **`gen_ai.client.cost`** - Monotonic counter of estimated cost in USD.
Incremented on every successful call. Use this for real-time cost dashboards
and budget alerting.
- **`gen_ai.client.retry.count`** - Counter of retry attempts. Incremented when
an LLM call fails and is retried (not on the first attempt). A rising retry
rate signals provider instability.
- **`gen_ai.client.fallback.count`** - Counter of fallback activations.
Incremented when the primary provider fails all retries and the application
switches to the fallback provider.
- **`gen_ai.client.error.count`** - Counter of LLM call errors. Carries
`error.type` as a label for classification (`rate_limit`, `timeout`,
`auth_error`, etc.).
All metrics carry a common set of labels (attributes) for grouping and
filtering:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
private static Attributes providerModelAttrs(String provider, String model) {
return Attributes.of(
AttributeKey.stringKey("gen_ai.operation.name"), "chat",
AttributeKey.stringKey("gen_ai.provider.name"), provider,
AttributeKey.stringKey("gen_ai.request.model"), model
);
}
private static Attributes withTokenType(Attributes base, String tokenType) {
return base.toBuilder()
.put(AttributeKey.stringKey("gen_ai.token.type"), tokenType)
.build();
}
```
#### Metrics Reference
| Metric Name | Type | Unit | Labels | Recorded In |
| ---------------------------------- | ------------- | --------- | -------------------------------------------------------------------------------------------- | --------------------- |
| `gen_ai.client.token.usage` | Histogram | `{token}` | `gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.request.model`, `gen_ai.token.type` | `generateOnce()` |
| `gen_ai.client.operation.duration` | Histogram | `s` | `gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.request.model` | `generateOnce()` |
| `gen_ai.client.cost` | DoubleCounter | `usd` | `gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.request.model` | `generateOnce()` |
| `gen_ai.client.retry.count` | LongCounter | - | `gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.request.model` | `generateWithRetry()` |
| `gen_ai.client.fallback.count` | LongCounter | - | - | `generate()` |
| `gen_ai.client.error.count` | LongCounter | - | `gen_ai.provider.name`, `gen_ai.request.model`, `error.type` | `generateOnce()` |
#### Cost Calculation
The `Pricing` class loads model pricing data at application startup and
calculates per-call costs based on input and output token counts. This decouples
cost calculation from the LLM call path - pricing data can be updated without
redeploying the application.
```java showLineNumbers title="src/main/java/com/example/support/llm/Pricing.java"
@Component
public class Pricing {
private static final Logger log = LoggerFactory.getLogger(Pricing.class);
private static final double FALLBACK_INPUT = 3.0;
private static final double FALLBACK_OUTPUT = 15.0;
private static final double PER_MILLION = 1_000_000.0;
private Map models = Map.of();
@JsonIgnoreProperties(ignoreUnknown = true)
record PricingFile(String version, Map models) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record ModelPricing(
String provider, double input, double output
) {}
@PostConstruct
void loadPricing() {
var objectMapper = new ObjectMapper();
String pricingFile = System.getenv("PRICING_FILE");
try {
InputStream stream;
if (pricingFile != null
&& Files.exists(Path.of(pricingFile))) {
stream = Files.newInputStream(Path.of(pricingFile));
log.info("Loaded pricing from {}", pricingFile);
} else {
stream = getClass().getClassLoader()
.getResourceAsStream("pricing.json");
if (stream == null) {
log.warn("No pricing.json found, "
+ "using fallback pricing");
return;
}
log.info("Loaded pricing from classpath");
}
var file = objectMapper.readValue(stream, PricingFile.class);
this.models = file.models();
log.info("Loaded pricing v{} with {} models",
file.version(), models.size());
} catch (IOException e) {
log.warn("Failed to load pricing.json: {}",
e.getMessage());
}
}
public double calculateCost(
String model, int inputTokens, int outputTokens
) {
var pricing = models.get(model);
double inputRate =
pricing != null ? pricing.input() : FALLBACK_INPUT;
double outputRate =
pricing != null ? pricing.output() : FALLBACK_OUTPUT;
return (inputTokens * inputRate
+ outputTokens * outputRate) / PER_MILLION;
}
public boolean hasModel(String model) {
return models.containsKey(model);
}
}
```
Key design decisions in the pricing implementation:
- **`@PostConstruct` loading** - Pricing data is loaded once at startup, not on
every LLM call. This avoids file I/O in the hot path.
- **External file override** - The `PRICING_FILE` environment variable allows
deploying updated pricing without rebuilding the application. If not set, the
classpath `pricing.json` is used.
- **Fallback rates** - If a model is not found in the pricing table, fallback
rates of $3.00/M input and $15.00/M output are used. These are intentionally
high to make unknown models visible in cost dashboards.
- **Per-million pricing** - Rates are stored as dollars per million tokens (the
standard unit used by LLM providers), and the `calculateCost()` method divides
by 1,000,000 to produce the actual cost per call.
#### How Metrics Are Recorded
Metrics are recorded at three points in the call chain, each capturing a
different aspect of LLM operations.
**Successful calls in `generateOnce()`** - Token usage, duration, and cost are
recorded after a successful ChatModel call:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
var attrs = providerModelAttrs(providerName, responseModel);
tokenUsage.record(inputTokens, withTokenType(attrs, "input"));
tokenUsage.record(outputTokens, withTokenType(attrs, "output"));
operationDuration.record(duration, attrs);
costCounter.add(costUsd, attrs);
return new LlmResponse(content, responseModel, providerName,
inputTokens, outputTokens, costUsd, finishReason);
```
The token histogram is recorded twice - once for input tokens and once for
output tokens - with the `gen_ai.token.type` label distinguishing them. This
allows separate analysis of prompt size versus completion size.
**Retries in `generateWithRetry()`** - The retry counter is incremented on each
retry attempt (not on the first attempt):
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return generateOnce(chatModel, providerName, model,
systemPrompt, userPrompt, stage, toolCallbacks);
} catch (Exception e) {
// ...
if (attempt > 0) {
retryCounter.add(1,
providerModelAttrs(providerName, model));
}
// ...
}
}
```
**Fallbacks in `generate()`** - The fallback counter is incremented when the
primary provider exhausts all retries and the application switches to the
fallback:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
log.warn("Primary provider {} failed, falling back to {}",
config.provider(), config.fallbackProvider());
fallbackCounter.add(1);
```
**Errors in `generateOnce()`** - The error counter is incremented in the catch
block with the classified error type as a label:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
} catch (Exception e) {
span.setStatus(StatusCode.ERROR, e.getMessage());
span.setAttribute("error.type", classifyError(e));
errorCounter.add(1, Attributes.of(
AttributeKey.stringKey("gen_ai.provider.name"), providerName,
AttributeKey.stringKey("gen_ai.request.model"), model,
AttributeKey.stringKey("error.type"), classifyError(e)
));
throw e;
}
```
Cost is recorded in two places - as a span attribute (`gen_ai.usage.cost_usd`)
for per-request visibility in traces, and as a metric counter
(`gen_ai.client.cost`) for aggregated dashboards and alerting. The span
attribute lets you see the cost of a single request when investigating a trace.
The metric counter lets you build a real-time cost dashboard that sums cost
across all requests, grouped by provider and model.
### Pipeline Observability
The support pipeline orchestrates six stages - classify intent, retrieve RAG
context, generate response, scrub PII, check escalation, and persist results.
Each stage runs as a child span under a single parent `support_conversation`
span, producing a trace that shows the full request lifecycle with timing and
attributes at every step.
The `SupportPipeline` class is the orchestrator. Its `process()` method handles
the reactive layer (load or create conversation, add user message, fetch
history), then delegates to `runPipeline()` on a bounded elastic scheduler for
the blocking pipeline work. Here is `runPipeline()` - the parent span and the
six-stage flow:
```java showLineNumbers title="src/main/java/com/example/support/pipeline/SupportPipeline.java"
private PipelineResult runPipeline(
String userMessage, UUID conversationId, List history
) {
long startNanos = System.nanoTime();
Span span = tracer.spanBuilder("support_conversation")
.setAttribute("support.conversation_id", conversationId.toString())
.startSpan();
try (Scope ignored = span.makeCurrent()) {
// 1. Classify intent (fast model)
IntentResult intent = intentClassifier.classify(userMessage);
span.setAttribute("support.intent", intent.intent().name());
span.setAttribute("support.confidence", intent.confidence());
// 2. Retrieve RAG context
var ragDocs = contextRetriever.retrieve(userMessage);
span.setAttribute("support.rag_matches", ragDocs.size());
// 3. Generate response (capable model)
String conversationHistory =
conversationService.formatHistory(history);
LlmResponse response = responseGenerator.generate(
userMessage, intent, ragDocs, conversationHistory);
// 4. PII scrub
String content = piiFilter.scrub(response.content());
// 5. Check escalation
int turns = history.size() / 2 + 1;
EscalationDecision escalation =
escalationRouter.evaluate(intent, turns, 0);
span.setAttribute(
"support.should_escalate", escalation.shouldEscalate());
// 6. Record domain metrics
if (!ragDocs.isEmpty()) {
Double topScore = ragDocs.getFirst().getScore();
if (topScore != null) {
metrics.recordRagSimilarity(
topScore, intent.intent().name());
}
}
metrics.recordConversationTurns(
turns, intent.intent().name(), false);
if (escalation.shouldEscalate()) {
metrics.recordEscalation(
escalation.reason(), escalation.priority().name());
}
double durationSec =
(System.nanoTime() - startNanos) / 1_000_000_000.0;
metrics.recordConversationDuration(
durationSec, intent.intent().name(),
escalation.shouldEscalate());
// Record totals
int totalTokens = intent.inputTokens() + intent.outputTokens()
+ response.inputTokens() + response.outputTokens();
span.setAttribute("support.total_turns", (long) turns);
span.setAttribute("support.total_tokens", (long) totalTokens);
span.setAttribute("support.total_cost_usd", response.costUsd());
return new PipelineResult(
content, intent, escalation,
response.model(), response.provider(),
response.inputTokens(), response.outputTokens(),
response.costUsd(), conversationId);
} catch (Exception e) {
span.setStatus(StatusCode.ERROR, e.getMessage());
throw new RuntimeException(
"Pipeline failed: " + e.getMessage(), e);
} finally {
span.end();
}
}
```
The span lifecycle follows the standard OpenTelemetry pattern: create with
`spanBuilder()`, set initial attributes, make it current with `makeCurrent()`
inside a try-with-resources so child spans automatically parent to it, set
additional attributes as the pipeline progresses, call `setStatus(ERROR)` in the
catch block, and call `end()` in the finally block. The `makeCurrent()` call is
the key - it puts this span on the thread-local context so that every child span
created by `intentClassifier.classify()`, `contextRetriever.retrieve()`, and the
other stages automatically becomes a child of `support_conversation`.
The parent span accumulates summary attributes as each stage completes:
`support.intent` and `support.confidence` after classification,
`support.rag_matches` after retrieval, `support.should_escalate` after the
escalation check, and `support.total_turns`, `support.total_tokens`, and
`support.total_cost_usd` at the end. This means you can filter traces by intent,
escalation status, or token count without expanding the span tree.
Each pipeline stage creates its own child span with a `support.stage` attribute.
Here are the four stage spans.
**IntentClassifier** - The `classify_intent` span wraps a fast-model LLM call
that returns structured JSON with the detected intent, confidence score,
sub-category, and extracted entities:
```java showLineNumbers title="src/main/java/com/example/support/pipeline/IntentClassifier.java"
public IntentResult classify(String userMessage) {
Span span = tracer.spanBuilder("classify_intent")
.setAttribute("support.stage", "classify")
.startSpan();
try (Scope ignored = span.makeCurrent()) {
LlmResponse response = llmService.generateFast(
SYSTEM_PROMPT, userMessage, "classify");
IntentResult result = parseResponse(response);
span.setAttribute("support.intent", result.intent().name());
span.setAttribute("support.confidence", result.confidence());
span.setAttribute(
"support.sub_category", result.subCategory());
if (!result.entities().isEmpty()) {
span.setAttribute(
"support.entities",
String.join(",", result.entities()));
}
return result;
} catch (Exception e) {
span.setStatus(StatusCode.ERROR, e.getMessage());
return IntentResult.fallback();
} finally {
span.end();
}
}
```
The `parseResponse()` method parses the LLM's JSON output, strips any markdown
code fences, and extracts the intent, confidence, sub-category, and entities
fields. If JSON parsing fails (malformed response, unexpected format), it falls
back to `Intent.QUERY` with a confidence of 0.3 and a sub-category of
`"parse_error"` - the pipeline continues with a degraded classification rather
than failing entirely.
**ContextRetriever** - The `rag_retrieval` span wraps the vector similarity
search:
```java showLineNumbers title="src/main/java/com/example/support/pipeline/ContextRetriever.java"
public List retrieve(String userMessage) {
Span span = tracer.spanBuilder("rag_retrieval")
.setAttribute("support.stage", "retrieve")
.startSpan();
try (Scope ignored = span.makeCurrent()) {
List results = vectorStore.similaritySearch(
SearchRequest.builder()
.query(userMessage)
.topK(TOP_K)
.build()
);
span.setAttribute("support.matches_found", results.size());
if (!results.isEmpty()) {
Double topScore = results.getFirst().getScore();
if (topScore != null) {
span.setAttribute(
"support.top_similarity", topScore);
}
}
return results;
} catch (Exception e) {
span.setStatus(
io.opentelemetry.api.trace.StatusCode.ERROR,
e.getMessage());
return List.of();
} finally {
span.end();
}
}
```
Like the classifier, the retriever returns an empty list on failure rather than
propagating the exception - the pipeline generates a response without RAG
context rather than failing the entire request.
**ResponseGenerator** - The `generate_response` span wraps the capable-model LLM
call that produces the final customer-facing response:
```java showLineNumbers title="src/main/java/com/example/support/pipeline/ResponseGenerator.java"
public LlmResponse generate(
String userMessage, IntentResult intent,
List ragContext, String conversationHistory
) {
Span span = tracer.spanBuilder("generate_response")
.setAttribute("support.stage", "generate")
.setAttribute("support.rag_matches_used", ragContext.size())
.startSpan();
try (Scope ignored = span.makeCurrent()) {
String ragSection =
contextRetriever.formatContext(ragContext);
String historySection =
conversationHistory != null
&& !conversationHistory.isEmpty()
? "Previous conversation:\n"
+ conversationHistory + "\n"
: "";
String systemPrompt = SYSTEM_PROMPT_TEMPLATE.formatted(
intent.intent().name(),
intent.confidence() * 100,
ragSection,
historySection
);
LlmResponse response = llmService.generateCapable(
systemPrompt, userMessage, "generate",
toolCallbacks);
span.setAttribute("gen_ai.usage.input_tokens",
(long) response.inputTokens());
span.setAttribute("gen_ai.usage.output_tokens",
(long) response.outputTokens());
span.setAttribute("gen_ai.usage.cost_usd",
response.costUsd());
return response;
} catch (Exception e) {
span.setStatus(StatusCode.ERROR, e.getMessage());
throw e;
} finally {
span.end();
}
}
```
The response generator does not catch-and-continue like the classifier and
retriever - a failed response generation is a hard failure that propagates up to
the parent span. The `rag_matches_used` attribute is set at span creation time
so it appears even if the LLM call fails, which helps diagnose whether failures
correlate with RAG context size.
**EscalationRouter** - The `escalation_check` span wraps the rule-based
escalation evaluation:
```java showLineNumbers title="src/main/java/com/example/support/pipeline/EscalationRouter.java"
public EscalationDecision evaluate(
IntentResult intent, int conversationTurns, int toolErrors
) {
Span span = tracer.spanBuilder("escalation_check")
.setAttribute("support.stage", "route")
.setAttribute("support.conversation_turns",
(long) conversationTurns)
.startSpan();
try (Scope ignored = span.makeCurrent()) {
EscalationDecision decision =
checkTriggers(intent, conversationTurns, toolErrors);
span.setAttribute("support.should_escalate",
decision.shouldEscalate());
if (decision.shouldEscalate()) {
span.setAttribute("support.escalation_reason",
decision.reason());
span.setAttribute("support.escalation_priority",
decision.priority().name());
}
return decision;
} finally {
span.end();
}
}
```
The `checkTriggers()` method evaluates five rules in priority order:
```java showLineNumbers title="src/main/java/com/example/support/pipeline/EscalationRouter.java"
EscalationDecision checkTriggers(
IntentResult intent, int conversationTurns, int toolErrors
) {
// Explicit ESCALATE intent - immediate
if (intent.intent() == Intent.ESCALATE) {
return EscalationDecision.escalate(
"explicit_request", EscalationPriority.HIGH,
"Customer explicitly requested human agent");
}
// Complaint + low confidence (< 0.6) - auto-escalate
if (intent.intent() == Intent.COMPLAINT
&& intent.confidence() < 0.6) {
return EscalationDecision.escalate(
"low_confidence_complaint", EscalationPriority.HIGH,
"Complaint with low classification confidence");
}
// 2+ tool errors - escalate with context
if (toolErrors >= 2) {
return EscalationDecision.escalate(
"tool_errors", EscalationPriority.MEDIUM,
"Multiple tool call failures (" + toolErrors + ")");
}
// Low intent confidence (< 0.5) - offer human agent
if (intent.confidence() < 0.5) {
return EscalationDecision.escalate(
"low_confidence", EscalationPriority.LOW,
"Low intent classification confidence");
}
// > 5 turns without resolution - suggest escalation
if (conversationTurns > 5) {
return EscalationDecision.escalate(
"long_conversation", EscalationPriority.LOW,
"Conversation exceeds 5 turns without resolution");
}
return EscalationDecision.noEscalation();
}
```
The rules are ordered by urgency. An explicit escalation request or a
low-confidence complaint triggers HIGH priority - these go to the front of the
human agent queue. Tool errors indicate the AI cannot fulfil the request and get
MEDIUM priority. Low confidence and long conversations get LOW priority as soft
suggestions. The span records `support.escalation_reason` and
`support.escalation_priority` only when escalation triggers, keeping clean
traces for normal conversations.
#### Tool Calling
Spring AI provides the `@Tool` and `@ToolParam` annotations for declarative tool
definitions. The LLM decides when to call a tool based on the tool's
description, and Spring AI handles the function-calling protocol with the
provider. Each tool method receives typed parameters, executes business logic
(typically a database query), and returns a result that Spring AI serializes
back to the LLM.
Here is an example tool method from `OrderTools` - the `getOrderStatus` tool
that looks up an order by ID:
```java showLineNumbers title="src/main/java/com/example/support/tools/OrderTools.java"
@Tool(description = "Look up order status and tracking info "
+ "by order ID (e.g. ORD-12345)")
public Map getOrderStatus(
@ToolParam(description = "Order ID, e.g. ORD-12345")
String orderId
) {
log.info("Tool call: getOrderStatus({})", orderId);
metrics.recordToolCall("getOrderStatus", true);
var rows = jdbc.queryForList(
"""
SELECT o.order_id, o.status, o.tracking_number,
o.estimated_delivery, o.total_amount,
o.created_at, c.name as customer_name
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.order_id = ?
""", orderId);
if (rows.isEmpty()) {
return Map.of("error", "Order not found: " + orderId);
}
return rows.getFirst();
}
```
Every `@Tool` method calls `metrics.recordToolCall()` with the tool name and
success status, feeding the `support.tool_calls` counter metric. The JDBC query
runs under the OpenTelemetry Java Agent's auto-instrumentation, so the database
call appears as a child span of the `gen_ai.chat` span without any manual
instrumentation.
Tool callbacks are assembled in the `ResponseGenerator` constructor using Spring
AI's `MethodToolCallbackProvider`:
```java showLineNumbers title="src/main/java/com/example/support/pipeline/ResponseGenerator.java"
this.toolCallbacks = List.of(
MethodToolCallbackProvider.builder()
.toolObjects(orderTools, productTools)
.build()
.getToolCallbacks()
);
```
This scans the `orderTools` and `productTools` beans for `@Tool`-annotated
methods and builds `ToolCallback` instances for each one. The callbacks are then
passed to `LlmService.generateCapable()`, which constructs
`ToolCallingChatOptions` when tools are present:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
if (toolCallbacks != null && !toolCallbacks.isEmpty()) {
var options = ToolCallingChatOptions.builder()
.model(model)
.temperature(config.temperature())
.maxTokens(config.maxTokens())
.toolCallbacks(toolCallbacks)
.build();
return new Prompt(messages, options);
}
```
The `ToolCallingChatOptions` extends the standard `ChatOptions` with tool
callback support. Spring AI handles the multi-turn tool-calling loop internally
— the LLM requests a tool call, Spring AI executes the matching `@Tool` method,
sends the result back to the LLM, and the LLM produces its final response. All
of this happens within the single `chatModel.call(prompt)` invocation.
The application exposes six tools across two classes:
| Tool | Class | Description |
| ----------------- | ------------ | --------------------------------------------- |
| `getOrderStatus` | OrderTools | Look up order status and tracking by order ID |
| `getOrderHistory` | OrderTools | Get recent orders by customer email |
| `initiateReturn` | OrderTools | Initiate a return for a delivered order |
| `getReturnStatus` | OrderTools | Check return status by return ID |
| `searchProducts` | ProductTools | Search product catalog by name or category |
| `getProductInfo` | ProductTools | Get product details by SKU |
#### RAG Retrieval
The RAG pipeline uses pgvector for vector storage with Spring AI's
`PgVectorStore` abstraction. The vector store is configured in
`VectorStoreConfig` with an HNSW index for fast approximate nearest-neighbor
search:
```java showLineNumbers title="src/main/java/com/example/support/config/VectorStoreConfig.java"
@Bean
PgVectorStore vectorStore(
EmbeddingModel embeddingModel, DataSource dataSource,
@Value("${spring.ai.vectorstore.pgvector.dimensions:1536}")
int dimensions
) {
return PgVectorStore.builder(
new JdbcTemplate(dataSource), embeddingModel)
.dimensions(dimensions)
.distanceType(PgDistanceType.COSINE_DISTANCE)
.indexType(PgIndexType.HNSW)
.initializeSchema(true)
.build();
}
```
The `dimensions` parameter defaults to 1536 (OpenAI's `text-embedding-ada-002`
output size) but is configurable for other embedding models. The `HNSW` index
type provides fast approximate search at the cost of more memory than IVFFlat.
`COSINE_DISTANCE` is the standard similarity metric for text embeddings.
`initializeSchema(true)` creates the `vector_store` table and index on startup
if they do not exist.
The `DataSource` uses HikariCP connection pooling:
```java showLineNumbers title="src/main/java/com/example/support/config/VectorStoreConfig.java"
@Bean
DataSource dataSource(
@Value("${spring.datasource.url}") String url,
@Value("${spring.datasource.username}") String username,
@Value("${spring.datasource.password}") String password
) {
var ds = new HikariDataSource();
ds.setJdbcUrl(url);
ds.setUsername(username);
ds.setPassword(password);
return ds;
}
```
On startup, `KnowledgeBaseService` loads knowledge base articles into the vector
store. It implements `ApplicationRunner` so it runs after the Spring context is
fully initialized:
```java showLineNumbers title="src/main/java/com/example/support/service/KnowledgeBaseService.java"
@Override
public void run(ApplicationArguments args) {
int existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM vector_store", Integer.class);
if (existing > 0) {
log.info("Vector store already populated with {} "
+ "documents, skipping KB load", existing);
return;
}
log.info("Loading KB articles into vector store...");
List> articles = jdbcTemplate.queryForList(
"SELECT id, intent, question, answer, category "
+ "FROM kb_articles");
List docs = articles.stream()
.map(row -> {
String content = row.get("question")
+ "\n\n" + row.get("answer");
Map metadata = Map.of(
"intent", row.get("intent"),
"category", row.get("category") != null
? row.get("category") : "",
"source", "kb_article",
"article_id", row.get("id").toString()
);
return new Document(content, metadata);
})
.toList();
vectorStore.add(docs);
log.info("Loaded {} KB articles into vector store",
docs.size());
}
```
The startup check (`SELECT COUNT(*) FROM vector_store`) prevents duplicate
embeddings on restart. Each document combines the question and answer text, with
metadata for intent, category, source type, and article ID. The
`vectorStore.add()` call handles embedding generation (via the configured
`EmbeddingModel`) and insertion in a single operation.
At query time, `ContextRetriever.retrieve()` runs a similarity search with
`topK(5)`:
```java showLineNumbers title="src/main/java/com/example/support/pipeline/ContextRetriever.java"
List results = vectorStore.similaritySearch(
SearchRequest.builder()
.query(userMessage)
.topK(TOP_K)
.build()
);
```
The returned documents are formatted for the system prompt by `formatContext()`:
```java showLineNumbers title="src/main/java/com/example/support/pipeline/ContextRetriever.java"
public String formatContext(List documents) {
if (documents.isEmpty()) {
return "";
}
var sb = new StringBuilder(
"Relevant knowledge base articles:\n\n");
for (int i = 0; i < documents.size(); i++) {
var doc = documents.get(i);
sb.append("--- Article ").append(i + 1)
.append(" ---\n");
sb.append(doc.getText()).append("\n\n");
}
return sb.toString();
}
```
This produces a numbered list of articles injected into the system prompt, so
the LLM can reference specific knowledge base content in its response. The top
similarity score is recorded as both a span attribute (`support.top_similarity`
on the `rag_retrieval` span) and a metric (`support.rag.similarity` histogram)
for tracking retrieval quality over time.
### Retry and Fallback Observability
The `LlmService` implements a two-tier resilience strategy: retry with
exponential backoff within a single provider, and fallback to an alternate
provider when all retries are exhausted. Both tiers are instrumented with
metrics.
The `generateWithRetry()` method handles the retry loop for a single provider:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
private LlmResponse generateWithRetry(
ChatModel chatModel, String providerName, String model,
String systemPrompt, String userPrompt, String stage,
List toolCallbacks
) {
Exception lastError = null;
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return generateOnce(
chatModel, providerName, model,
systemPrompt, userPrompt, stage,
toolCallbacks);
} catch (Exception e) {
lastError = e;
log.warn("LLM call failed (attempt {}/{}): "
+ "provider={} model={} error={}",
attempt + 1, MAX_RETRIES,
providerName, model, e.getMessage());
if (attempt > 0) {
retryCounter.add(1,
providerModelAttrs(providerName, model));
}
if (attempt < MAX_RETRIES - 1) {
sleep(backoffWithJitter(attempt));
}
}
}
log.error("All {} retries exhausted for provider={}",
MAX_RETRIES, providerName, lastError);
return null;
}
```
The retry loop makes up to `MAX_RETRIES` (3) attempts. On failure, it records
the retry attempt to the `gen_ai.client.retry.count` counter (skipping the first
attempt since that is not a retry). The backoff uses exponential delay with
jitter:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
private long backoffWithJitter(int attempt) {
long base = Math.min(
MIN_BACKOFF_MS * (1L << attempt), MAX_BACKOFF_MS);
long jitter = ThreadLocalRandom.current()
.nextLong(0, base / 4 + 1);
return base + jitter;
}
```
This produces delays of approximately 1s, 2s, and 4s for attempts 0, 1, and 2,
capped at 10s, with up to 25% random jitter added to prevent thundering-herd
retries across concurrent requests. The method returns `null` after exhausting
all retries rather than throwing - this signals the caller to try the fallback
provider.
The `generate()` method orchestrates the primary-to-fallback flow:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
public LlmResponse generate(
String systemPrompt, String userPrompt,
String model, String stage,
List toolCallbacks
) {
var resp = generateWithRetry(
primaryModel, config.provider(), model,
systemPrompt, userPrompt, stage, toolCallbacks);
if (resp != null) {
return resp;
}
log.warn("Primary provider {} failed, falling back to {}",
config.provider(), config.fallbackProvider());
fallbackCounter.add(1);
resp = generateWithRetry(
fallbackModel, config.fallbackProvider(),
config.fallbackModel(),
systemPrompt, userPrompt, stage, toolCallbacks);
if (resp != null) {
return resp;
}
throw new RuntimeException(
"All LLM providers failed after retries");
}
```
The flow is: try the primary provider with up to 3 retries. If all fail (
`generateWithRetry` returns `null`), increment `gen_ai.client.fallback.count`
and try the fallback provider with another 3 retries. If both providers fail,
throw a `RuntimeException` that propagates up to the pipeline's catch block and
sets the parent span status to ERROR.
In telemetry, retries and fallbacks surface through three metrics defined in the
LLM metrics section:
- `gen_ai.client.retry.count` - incremented on each retry attempt (not the
initial attempt), labeled with `gen_ai.provider.name` and
`gen_ai.request.model`. A steady increase indicates provider instability.
- `gen_ai.client.fallback.count` - incremented once per fallback activation. Any
non-zero value means the primary provider failed completely for at least one
request.
- `gen_ai.client.error.count` - incremented on every failed `generateOnce()`
call, labeled with `error.type` (rate_limit, timeout, auth_error,
invalid_request, server_error, network_error, unknown_error). This gives
visibility into why retries are happening.
### Domain Metrics
The GenAI metrics from the previous sections cover LLM operational concerns —
token usage, cost, latency, errors. Domain metrics capture the business-level
signals that tell you whether the AI application is actually working for your
users: how long conversations last, how many turns they take, when they escalate
to humans, which tools the LLM calls, and how relevant the RAG results are.
`SupportMetrics` defines five domain-specific metrics using the OpenTelemetry
Meter API:
```java showLineNumbers title="src/main/java/com/example/support/telemetry/SupportMetrics.java"
@Component
public class SupportMetrics {
private final DoubleHistogram conversationDuration;
private final DoubleHistogram conversationTurns;
private final LongCounter escalationCount;
private final LongCounter toolCallCount;
private final DoubleHistogram ragSimilarity;
public SupportMetrics() {
Meter meter = GlobalOpenTelemetry.getMeter(
"ai-customer-support");
this.conversationDuration = meter
.histogramBuilder("support.conversation.duration")
.setUnit("s")
.setDescription("Duration of customer support "
+ "conversations")
.build();
this.conversationTurns = meter
.histogramBuilder("support.conversation.turns")
.setUnit("{turn}")
.setDescription("Number of turns in customer "
+ "support conversations")
.build();
this.escalationCount = meter
.counterBuilder("support.escalation.count")
.setDescription(
"Number of escalated conversations")
.build();
this.toolCallCount = meter
.counterBuilder("support.tool_calls")
.setDescription("Number of tool calls made")
.build();
this.ragSimilarity = meter
.histogramBuilder("support.rag.similarity")
.setDescription("Top similarity score from "
+ "RAG retrieval")
.build();
}
public void recordConversationDuration(
double seconds, String intent, boolean escalated
) {
conversationDuration.record(seconds, Attributes.of(
AttributeKey.stringKey("support.intent"), intent,
AttributeKey.booleanKey("support.escalated"),
escalated
));
}
public void recordConversationTurns(
int turns, String intent, boolean resolved
) {
conversationTurns.record(turns, Attributes.of(
AttributeKey.stringKey("support.intent"), intent,
AttributeKey.booleanKey("support.resolved"), resolved
));
}
public void recordEscalation(String reason, String priority) {
escalationCount.add(1, Attributes.of(
AttributeKey.stringKey("support.escalation_reason"),
reason,
AttributeKey.stringKey("support.escalation_priority"),
priority
));
}
public void recordToolCall(String toolName, boolean success) {
toolCallCount.add(1, Attributes.of(
AttributeKey.stringKey("support.tool_name"), toolName,
AttributeKey.booleanKey("support.tool_success"), success
));
}
public void recordRagSimilarity(
double similarity, String intent
) {
ragSimilarity.record(similarity, Attributes.of(
AttributeKey.stringKey("support.intent"), intent
));
}
}
```
Each metric is recorded at a specific point in the pipeline:
- `support.conversation.duration` and `support.conversation.turns` - recorded at
the end of `SupportPipeline.runPipeline()`, after all stages complete.
Duration is measured from the start of `runPipeline()` in seconds. Turns are
calculated as `history.size() / 2 + 1` (each turn is a user-assistant pair).
- `support.escalation.count` - recorded in `SupportPipeline.runPipeline()`
immediately after the escalation check, only when
`escalation.shouldEscalate()` returns true.
- `support.tool_calls` - recorded inside each `@Tool` method in `OrderTools` and
`ProductTools`. Every tool call increments the counter with the tool name and
success status.
- `support.rag.similarity` - recorded in `SupportPipeline.runPipeline()` after
RAG retrieval, using the top document's similarity score.
| Metric | Type | Unit | Labels | Business Purpose |
| ------------------------------- | --------- | -------- | ---------------------------------------------------------- | ------------------------------------------------------------------- |
| `support.conversation.duration` | Histogram | `s` | `support.intent`, `support.escalated` | Track resolution time by intent type and escalation status |
| `support.conversation.turns` | Histogram | `{turn}` | `support.intent`, `support.resolved` | Detect long conversations that may need UX improvements |
| `support.escalation.count` | Counter | - | `support.escalation_reason`, `support.escalation_priority` | Monitor escalation rate and reasons for human handoff |
| `support.tool_calls` | Counter | - | `support.tool_name`, `support.tool_success` | Track which tools the LLM uses and their success rate |
| `support.rag.similarity` | Histogram | - | `support.intent` | Monitor retrieval quality - low scores indicate knowledge base gaps |
### PII and Security
AI applications handle user input that may contain personally identifiable
information - email addresses, social security numbers, credit card numbers,
phone numbers. The `PiiFilter` scrubs PII from both response content (returned
to the client) and telemetry data (exported to the collector) so sensitive data
does not leak into traces or logs.
The filter uses regex patterns for four PII categories:
```java showLineNumbers title="src/main/java/com/example/support/filter/PiiFilter.java"
@Component
public class PiiFilter {
private static final String REDACTED = "[REDACTED]";
private record PiiPattern(String name, Pattern pattern) {}
private static final List PATTERNS = List.of(
new PiiPattern("email",
Pattern.compile(
"\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+"
+ "\\.[A-Za-z]{2,}\\b")),
new PiiPattern("ssn",
Pattern.compile("\\b\\d{3}-\\d{2}-\\d{4}\\b")),
new PiiPattern("credit_card",
Pattern.compile(
"\\b\\d{4}[- ]?\\d{4}[- ]?\\d{4}[- ]?"
+ "\\d{4}\\b")),
new PiiPattern("phone",
Pattern.compile(
"(?:\\+?1[-.]?)?\\(?\\d{3}\\)?[-.]?"
+ "\\d{3}[-.]?\\d{4}"))
);
public String scrub(String text) {
if (text == null || text.isEmpty()) return text;
String result = text;
boolean piiFound = false;
for (var pii : PATTERNS) {
var matcher = pii.pattern().matcher(result);
if (matcher.find()) {
piiFound = true;
log.warn("PII detected (type={}), redacting",
pii.name());
result = matcher.replaceAll(REDACTED);
}
}
if (piiFound) {
Span current = Span.current();
current.addEvent("support.pii_detected",
Attributes.of(
AttributeKey.booleanKey(
"support.pii_redacted"), true
));
}
return result;
}
}
```
When PII is detected and redacted, the filter adds a `support.pii_detected` span
event to the current active span with `support.pii_redacted = true`. This
provides an audit trail in traces - you can see that PII was present and
scrubbed without the trace containing the actual PII data.
The filter is applied at two points in the pipeline:
1. **Response content** - `PiiFilter.scrub()` is called on the LLM response in
`SupportPipeline.runPipeline()` before returning content to the client:
```java showLineNumbers title="src/main/java/com/example/support/pipeline/SupportPipeline.java"
// 4. PII scrub
String content = piiFilter.scrub(response.content());
```
2. **Span events** - In `LlmService.generateOnce()`, the PII filter scrubs
prompt and completion content before writing it to span events:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
if (captureContent) {
span.addEvent("gen_ai.user.message", Attributes.of(
AttributeKey.stringKey("gen_ai.prompt"),
truncate(piiFilter.scrub(userPrompt), 1000)
));
// ...
}
```
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
if (captureContent) {
span.addEvent("gen_ai.assistant.message", Attributes.of(
AttributeKey.stringKey("gen_ai.completion"),
truncate(piiFilter.scrub(content), 2000)
));
}
```
Content capture itself is gated by the
`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` environment variable. The
application checks this at startup:
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
this.captureContent = "true".equalsIgnoreCase(
System.getenv(
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"));
```
When this variable is unset or set to anything other than `"true"`, no prompt or
completion content is written to span events at all - a defense-in-depth
approach where PII filtering is the second layer after the content capture gate.
Additional security practices in the application:
- **API keys via environment variables** - Provider API keys (`OPENAI_API_KEY`,
`ANTHROPIC_API_KEY`, `GOOGLE_API_KEY`) are injected via environment variables,
never hardcoded in source or configuration files.
- **Content truncation limits** - Prompts are truncated to 1000 characters,
system instructions to 500 characters, and completions to 2000 characters
before writing to span events. This prevents large payloads from inflating
trace storage costs.
- **PII scrubbing before telemetry export** - The PII filter runs before content
reaches OpenTelemetry span events, so sensitive data never leaves the
application process.
- **Spring AI observation content capture** - Spring AI's built-in Micrometer
observations (Layer 2) have their own content capture setting
(`spring.ai.chat.observations.include-input` /
`spring.ai.chat.observations.include-output`) which defaults to false. This
means Spring AI's auto-generated spans also do not capture content by default.
### Running Your Application
**Development**
In development, run directly with Gradle without the Java Agent. Spring AI's
Micrometer observations and your manual OpenTelemetry spans still work - the
Java Agent just adds the automatic HTTP/JDBC layer on top.
Set your environment variables and start the application:
```bash showLineNumbers
# Set environment variables
export OPENAI_API_KEY=sk-...
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_SERVICE_NAME=ai-customer-support
# Run with Spring Boot
./gradlew bootRun
```
For local development with Ollama (no API key needed):
```bash showLineNumbers
SPRING_PROFILES_ACTIVE=ollama ./gradlew bootRun
```
Console output shows Spring AI observations and your manual spans. If you add a
`debug` exporter to a local collector, you will see full span details in the
collector logs.
Without the Java Agent, you get Layer 2 (Spring AI Micrometer observations) and
Layer 3 (manual OpenTelemetry API spans) but not Layer 1 (auto HTTP/JDBC spans).
This is fine for development - the two manual layers provide full GenAI context.
**Production**
In production, attach the OpenTelemetry Java Agent for the full three-layer
instrumentation stack:
```bash showLineNumbers
java -javaagent:/path/to/opentelemetry-javaagent.jar \
-Dotel.service.name=ai-customer-support \
-Dotel.exporter.otlp.endpoint=http://collector:4318 \
-Dotel.exporter.otlp.protocol=http/protobuf \
-Dotel.traces.exporter=otlp \
-Dotel.metrics.exporter=otlp \
-Dotel.logs.exporter=otlp \
-jar app.jar
```
Configure sampling to control trace volume in high-traffic environments:
```yaml showLineNumbers title="application-production.yml"
management:
tracing:
sampling:
probability: 0.1 # 10% sampling for high-traffic
```
Set resource attributes to identify the deployment in your observability
backend:
```bash showLineNumbers
export OTEL_RESOURCE_ATTRIBUTES="service.name=ai-customer-support,deployment.environment=demo,environment=demo,service.version=1.2.0"
```
For production, always route telemetry through an OpenTelemetry Collector rather
than exporting directly from the application. The collector provides buffering,
retry logic, and filtering that protects both your application and your backend.
**Docker Compose**
The Docker Compose setup runs the application with the Java Agent, a PostgreSQL
database with pgvector, and the OpenTelemetry Collector - the full production
stack locally.
The `Dockerfile` uses a multi-stage build that compiles the application, then
downloads the Java Agent into the runtime image:
```dockerfile showLineNumbers title="Dockerfile"
FROM gradle:9.2.1-jdk25 AS builder
WORKDIR /app
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
COPY src ./src
RUN gradle build -x test --no-daemon
FROM eclipse-temurin:25-jre
WORKDIR /app
ADD https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.25.0/opentelemetry-javaagent.jar /app/opentelemetry-javaagent.jar
COPY --from=shared pricing.json /app/pricing.json
COPY --from=builder /app/build/libs/ai-customer-support-0.0.1-SNAPSHOT.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", \
"-javaagent:/app/opentelemetry-javaagent.jar", \
"-jar", "/app/app.jar"]
```
The `compose.yml` wires together the application, database, and collector:
```yaml showLineNumbers title="compose.yml"
services:
app:
build:
context: .
additional_contexts:
shared: ../../_shared
ports:
- "8080:8080"
environment:
SPRING_R2DBC_URL: r2dbc:postgresql://postgres:5432/support
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/support
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: support
DB_USER: postgres
DB_PASSWORD: postgres
LLM_PROVIDER: ${LLM_PROVIDER:-openai}
LLM_MODEL_CAPABLE: ${LLM_MODEL_CAPABLE:-gpt-4.1}
LLM_MODEL_FAST: ${LLM_MODEL_FAST:-gpt-4.1-mini}
FALLBACK_PROVIDER: ${FALLBACK_PROVIDER:-anthropic}
FALLBACK_MODEL: ${FALLBACK_MODEL:-claude-haiku-4-5-20251001}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
GOOGLE_API_KEY: ${GOOGLE_API_KEY:-}
DEFAULT_TEMPERATURE: ${DEFAULT_TEMPERATURE:-0.3}
DEFAULT_MAX_TOKENS: ${DEFAULT_MAX_TOKENS:-1024}
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-text-embedding-3-small}
EMBEDDING_DIMENSIONS: ${EMBEDDING_DIMENSIONS:-1536}
EMBEDDING_PROVIDER: ${EMBEDDING_PROVIDER:-openai}
PRICING_FILE: /app/pricing.json
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-}
OTEL_SERVICE_NAME: ai-customer-support
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
OTEL_TRACES_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_LOGS_EXPORTER: otlp
OTEL_INSTRUMENTATION_COMMON_DEFAULT_ENABLED: "true"
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: ${OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT:-false}
depends_on:
postgres:
condition: service_healthy
otel-collector:
condition: service_started
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:8080/api/health",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
postgres:
image: pgvector/pgvector:pg18
ports:
- "5432:5432"
environment:
POSTGRES_DB: support
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
- ./db/seed.sql:/docker-entrypoint-initdb.d/02-seed.sql
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
otel-collector:
image: otel/opentelemetry-collector-contrib:0.146.0
command: ["--config=/etc/otel-collector-config.yaml"]
ports:
- "4317:4317"
- "4318:4318"
- "13133:13133"
volumes:
- ./config/otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
environment:
SCOUT_CLIENT_ID: ${SCOUT_CLIENT_ID:-}
SCOUT_CLIENT_SECRET: ${SCOUT_CLIENT_SECRET:-}
SCOUT_TOKEN_URL: ${SCOUT_TOKEN_URL:-https://auth.base14.io/oauth/token}
SCOUT_ENDPOINT: ${SCOUT_ENDPOINT:-https://collector.base14.io}
SCOUT_ENVIRONMENT: ${SCOUT_ENVIRONMENT:-development}
healthcheck:
test: ["NONE"]
volumes:
pgdata:
```
Start the stack and verify:
```bash showLineNumbers
# Start all services
docker compose up -d
# Check health
docker compose ps
curl http://localhost:8080/api/health
# View logs
docker compose logs -f app
```
The OpenTelemetry Collector configuration handles telemetry routing, filtering,
and export. This configuration includes the health check and zpages extensions
for collector diagnostics, a noise filter for health check and HikariCP
housekeeping spans, retry logic for the exporter, and a debug exporter for local
development:
```yaml showLineNumbers title="config/otel-collector-config.yaml"
extensions:
health_check:
endpoint: 0.0.0.0:13133
zpages:
endpoint: 0.0.0.0:55679
oauth2client:
client_id: ${SCOUT_CLIENT_ID}
client_secret: ${SCOUT_CLIENT_SECRET}
token_url: ${SCOUT_TOKEN_URL}
endpoint_params:
audience: b14collector
timeout: 10s
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
filter/noisy:
error_mode: ignore
traces:
span:
- 'IsMatch(name, ".*/health.*")'
- 'IsMatch(name, ".*/actuator.*")'
# HikariCP connection pool runs keepalive queries every ~30s on a
# housekeeper thread. The JDBC auto-instrumentation creates orphan
# single-span traces for these (no parent HTTP/pipeline context).
# Drop them to avoid polluting the trace store.
- 'attributes["thread.name"] != nil and
IsMatch(attributes["thread.name"], "HikariPool.*housekeeper")'
batch:
timeout: 10s
send_batch_size: 1024
send_batch_max_size: 2048
attributes:
actions:
- key: deployment.environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
- key: environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
exporters:
otlp_http/b14:
endpoint: ${SCOUT_ENDPOINT}
auth:
authenticator: oauth2client
compression: gzip
timeout: 30s
retry_on_failure:
enabled: true
initial_interval: 1s
max_interval: 30s
max_elapsed_time: 300s
debug:
verbosity: detailed
sampling_initial: 100
sampling_thereafter: 100
service:
extensions: [health_check, zpages, oauth2client]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, filter/noisy, attributes, batch]
exporters: [otlp_http/b14, debug]
metrics:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlp_http/b14, debug]
logs:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlp_http/b14, debug]
```
Key collector configuration details:
- **`health_check`** on port 13133 - used by Docker health checks and load
balancers to verify the collector is running
- **`zpages`** on port 55679 - provides live debugging pages at `/debug/tracez`
and `/debug/pipelinez` for inspecting the collector's internal state
- **`oauth2client`** - authenticates with base14 Scout using OAuth2 client
credentials
- **`memory_limiter`** - caps the collector at 512 MiB with a 128 MiB spike
buffer, preventing OOM in constrained environments
- **`filter/noisy`** - drops health check, actuator, and HikariCP housekeeper
spans that add volume without diagnostic value
- **`retry_on_failure`** - retries failed exports with exponential backoff from
1s to 30s, for up to 5 minutes total
- **`debug` exporter** - logs detailed span information locally, invaluable
during development and initial deployment validation
### Troubleshooting
Verify your deployment is working by sending a health check and a test message:
```bash showLineNumbers
# Health check
curl http://localhost:8080/api/health
# Send a test message
curl -X POST http://localhost:8080/api/chat \
-H "Content-Type: application/json" \
-d '{"message": "What is the status of order ORD-10001?"}'
```
The response should include the AI-generated answer along with metadata like
model name, token counts, and cost. The corresponding trace should appear in
your observability backend within a few seconds.
Enable debug logging to diagnose instrumentation issues:
```bash showLineNumbers
# Enable Java Agent debug logging
OTEL_LOG_LEVEL=debug docker compose up
# Enable Spring AI observation logging
# Add to application.yml or pass as -D flag:
# logging.level.org.springframework.ai=DEBUG
```
#### No traces appearing
Check that the collector endpoint URL matches between the application and the
collector. The application sends to `http://otel-collector:4318` (the Docker
service name), not `localhost`:
```bash showLineNumbers
# Check collector logs for incoming data
docker compose logs otel-collector
# Verify the Java Agent loaded
docker compose logs app | grep "opentelemetry-javaagent"
```
Confirm the Java Agent JAR path in the Dockerfile matches the download URL. If
the path is wrong, the JVM starts without the agent silently - no error, just no
Layer 1 spans.
#### Spring AI spans missing
Verify that `micrometer-tracing-bridge-otel` is in your `build.gradle`
dependencies. This bridge is what connects Spring AI's Micrometer observations
to OpenTelemetry. Without it, Spring AI creates observations but they never
become OpenTelemetry spans.
Check that sampling is not set to zero:
```yaml showLineNumbers title="application.yml"
management:
tracing:
sampling:
probability: 1.0 # Must be > 0; 1.0 for development
```
#### Duplicate spans from Java Agent and Spring AI
The Java Agent auto-instruments HTTP clients (Netty, Apache HttpClient, etc.),
and Spring AI creates its own ChatModel observation spans. This means a single
LLM call produces both an HTTP span (from the agent) and a ChatModel span (from
Spring AI). This is expected behavior, not a bug - the HTTP span shows network
timing while the ChatModel span shows model-level metadata.
If the volume is excessive, the `filter/noisy` processor in the collector
configuration can drop specific span patterns. But in most cases, both spans
provide useful and non-overlapping information.
#### pgvector connection errors
The application uses two separate database connections: R2DBC for reactive
repository operations and JDBC for pgvector vector store and tool-calling
queries. Both must be configured:
```yaml showLineNumbers title="application.yml"
spring:
r2dbc:
url: r2dbc:postgresql://postgres:5432/support
datasource:
url: jdbc:postgresql://postgres:5432/support
```
Verify the pgvector extension is enabled in the database:
```sql showLineNumbers
CREATE EXTENSION IF NOT EXISTS vector;
```
The seed SQL scripts (`db/schema.sql`) handle this automatically, but if you are
connecting to an existing database, you need the extension installed manually.
#### Tool calls not showing in traces
Verify tools are registered via `MethodToolCallbackProvider` and that you are
using `ToolCallingChatOptions` (not plain `ChatOptions`) when building prompts
for tool-enabled calls:
```java showLineNumbers
// Correct: ToolCallingChatOptions enables tool discovery
var options = ToolCallingChatOptions.builder()
.model(model)
.toolCallbacks(toolCallbacks)
.build();
// Wrong: plain ChatOptions ignores tool callbacks
var options = ChatOptions.builder()
.model(model)
.build();
```
Each `@Tool` method should call `SupportMetrics.recordToolCall()` to record the
tool invocation in your custom metrics. Without this call, the tool executes but
no `support.tool.calls` metric is emitted.
### Performance Considerations
Three-layer instrumentation adds measurable but minimal overhead to each
request:
| Layer | Latency Overhead | Memory | CPU |
| -------------------- | ----------------------- | ----------- | ---------- |
| Java Agent | 1-3ms per span | ~50MB heap | <1% |
| Spring AI Micrometer | <1ms per observation | Negligible | Negligible |
| Manual OTel API | <0.5ms per span | Negligible | Negligible |
| Combined | 2-5ms per request | ~60MB total | 1-2% |
For an AI application where LLM calls take 500ms-5s each, the 2-5ms
instrumentation overhead is negligible - well under 1% of total request latency.
Five practices to optimize instrumentation performance in production:
1. **Use sampling for high-traffic services.** Set
`management.tracing.sampling.probability` to 0.1-0.5 in production. A 10%
sample rate captures enough data for trend analysis while reducing trace
volume by 90%. For AI applications with relatively low request volume
(compared to CRUD APIs), you may keep 1.0 sampling.
2. **The memory_limiter processor prevents collector OOM.** The collector
configuration sets a 512 MiB limit with a 128 MiB spike buffer. When the
collector approaches the limit, it drops new telemetry rather than crashing.
This protects the collector process in memory-constrained container
environments.
3. **BatchSpanProcessor batches exports to reduce network calls.** The Java
Agent uses `BatchSpanProcessor` by default, which buffers spans and exports
them in batches (default: every 5 seconds or 512 spans, whichever comes
first). This means individual span creation never blocks on network I/O.
4. **Content capture is disabled by default.** The
`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` environment variable
defaults to false. Enabling it adds JSON serialization overhead for every
prompt and completion - significant for large payloads. Only enable content
capture during debugging or for specific trace sampling.
5. **Filter noisy spans in the collector.** The `filter/noisy` processor drops
health check, actuator, and HikariCP housekeeper spans. In a Spring Boot
application, actuator endpoints alone can generate dozens of spans per
minute. Filtering these at the collector level (rather than the application)
means the application still exports them for debugging if you temporarily
remove the filter.
### FAQ
#### How much overhead does OpenTelemetry add to a Spring AI application?
Combined overhead is 2-5ms per request with approximately 60MB additional heap
usage. For AI applications where LLM calls dominate latency (500ms-5s per call),
this is less than 1% overhead. The Java Agent's `BatchSpanProcessor` ensures
span creation never blocks on network I/O, and the Micrometer observation layer
adds sub-millisecond overhead per observation.
#### Do I need all three OpenTelemetry layers or can I use fewer?
Yes. Each layer is independent:
- **Layer 1 only (Java Agent)**: Add `-javaagent` flag. You get HTTP, JDBC, and
R2DBC spans with zero code changes, but no GenAI attributes.
- **Layer 2 only (Spring AI Micrometer)**: Add `micrometer-tracing-bridge-otel`
to dependencies. You get ChatModel and VectorStore spans with model names and
token counts.
- **Layer 3 only (Manual OTel API)**: Use `GlobalOpenTelemetry.getTracer()`. You
get full GenAI semantic conventions, custom metrics, and pipeline context.
- **Layer 1 + 2**: Auto HTTP/JDBC spans plus Spring AI observations. Good
coverage without any manual instrumentation code.
- **Layer 2 + 3**: Spring AI observations plus manual GenAI spans. Full AI
context without the Java Agent JAR.
The full three-layer stack provides the most complete traces, but any
combination works.
#### What Spring AI versions are compatible?
This guide uses Spring AI 2.0.0-M2 with Spring Boot 4.0.3. The key requirement
is that Spring AI must emit Micrometer observations (available since Spring AI
1.0.0-M1). The `micrometer-tracing-bridge-otel` dependency must match your
Spring Boot version's Micrometer version - Spring Boot's dependency management
BOM handles this automatically.
#### How do I reduce trace volume in production?
Four approaches, from least to most aggressive:
1. **Sampling**: Set `management.tracing.sampling.probability` to 0.1-0.5.
2. **Collector filtering**: The `filter/noisy` processor drops health checks,
actuator endpoints, and HikariCP housekeeping spans.
3. **Head-based sampling at the collector**: Add a `probabilistic_sampler`
processor to the collector pipeline for additional server-side sampling.
4. **Disable Layer 1**: Remove the Java Agent to eliminate HTTP/JDBC auto-spans
while keeping the AI-specific spans from Layers 2 and 3.
#### Can I use the OpenTelemetry Java Agent with Spring AI at the same time?
No. The Java Agent instruments at the bytecode level (HTTP clients, JDBC
drivers) while Spring AI observations operate at the application framework level
(ChatModel, VectorStore). They share the same OpenTelemetry context, so their
spans appear as parent-child in the same trace. The only overlap is HTTP client
spans - the Java Agent creates an HTTP span for the outbound LLM API call, and
Spring AI creates a ChatModel observation span. Both carry useful but different
information (network timing vs. model metadata).
#### How do I add a new LLM provider (e.g., Google Gemini)?
Add the Spring AI starter for the provider to `build.gradle`:
```groovy showLineNumbers
implementation 'org.springframework.ai:spring-ai-starter-model-vertex-ai-gemini'
```
Then register the ChatModel bean and add a case in
`LlmConfig.resolveChatModel()` to map the provider name to the bean. The
three-layer instrumentation works automatically - the Java Agent captures the
HTTP call to Google's API, Spring AI emits a ChatModel observation, and
`LlmService.generateOnce()` creates the GenAI span with the correct
`gen_ai.provider.name` attribute.
#### How do I track costs across multiple providers?
The `LlmService` loads pricing data from `pricing.json`, which maps model names
to per-token input and output costs. Each `generateOnce()` call calculates cost
using `pricing.calculateCost(responseModel, inputTokens, outputTokens)` and
records it to both the span attribute (`gen_ai.usage.cost_usd`) and the
`gen_ai.client.cost` metric counter. To add a new model, add its pricing to
`pricing.json`. To aggregate costs, query the `gen_ai.client.cost` metric
grouped by `gen_ai.provider.name` and `gen_ai.request.model`.
#### How should I handle PII in production telemetry?
The application applies two layers of PII protection:
1. **Content capture gate**: The
`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` environment variable
(default: false) controls whether prompts and completions are written to span
events at all. In production, leave this disabled.
2. **PII filter**: When content capture is enabled, the `PiiFilter` scrubs email
addresses, phone numbers, SSNs, and credit card numbers before writing to
span events. This is a defense-in-depth measure.
Span attributes like model name, token counts, and cost never contain PII and
are always safe to export.
#### Can I deploy to Kubernetes instead of Docker Compose?
Yes. The Docker Compose setup translates directly to Kubernetes:
- The `app` service becomes a Deployment with the same environment variables
- The `postgres` service becomes a StatefulSet or a managed database service
- The `otel-collector` becomes a DaemonSet or a sidecar container
- Environment variables from `.env` move to Kubernetes Secrets and ConfigMaps
- The collector config becomes a ConfigMap mounted as a volume
The application code and Dockerfile do not change. Only the orchestration layer
differs.
#### How do I debug missing GenAI attributes on spans?
If spans appear but lack `gen_ai.*` attributes, the issue is in Layer 3 (manual
instrumentation). Check these in order:
1. **Verify `GlobalOpenTelemetry.getTracer()` returns a real tracer.** If the
Java Agent is not loaded or the SDK is not initialized, it returns a no-op
tracer that creates spans silently discarded.
2. **Check that `span.setAttribute()` calls use the correct attribute names.**
The GenAI semantic conventions use underscores (`gen_ai.request.model`), not
dots or hyphens.
3. **Confirm the span is ended.** Attributes set on a span after `span.end()`
are ignored. The `try/finally` pattern in `generateOnce()` ensures the span
is always ended.
4. **Look at the debug exporter output.** The collector's `debug` exporter logs
every span with all attributes. If the attributes are present in the debug
output but missing in your backend, the issue is in the backend's indexing,
not the instrumentation.
### What's Next
#### Advanced Topics
- [Custom Java Instrumentation](../../instrument/apps/custom-instrumentation/java.md)
- manual spans and metrics for non-AI Java applications
- [Spring Boot Auto-Instrumentation](../../instrument/apps/auto-instrumentation/spring-boot.md)
- zero-code instrumentation for Spring Boot web applications
- [LLM Observability](../llm-observability) - Python patterns for LLM
observability
- [Rust LLM Observability](../rust-llm-observability) - Rust patterns with manual
GenAI instrumentation
#### Scout Platform Features
- [Creating Alerts](../../guides/creating-alerts-with-logx.md) - set up cost,
latency, and error rate alerts
- [Create Your First Dashboard](../../guides/create-your-first-dashboard.md) —
visualize AI metrics
#### Deployment and Operations
- [Docker Compose Setup](../../instrument/collector-setup/docker-compose-example.md)
- collector deployment reference
### Complete Example
The following files form a working deployment of the AI customer support
application with full three-layer observability. The complete source code is
available at
[github.com/base-14/examples/tree/main/java/ai-customer-support](https://github.com/base-14/examples/tree/main/java/ai-customer-support).
#### build.gradle
The dependencies include Spring AI with three LLM providers, the Micrometer
OpenTelemetry bridge, the OpenTelemetry API, pgvector for RAG, and both R2DBC
and JDBC database drivers:
```groovy showLineNumbers title="build.gradle"
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.3'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
repositories {
mavenCentral()
}
dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:2.0.0-M2"
}
}
dependencies {
// Web (reactive)
implementation 'org.springframework.boot:spring-boot-starter-webflux'
// Observability
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-tracing-bridge-otel'
implementation 'io.opentelemetry:opentelemetry-exporter-otlp'
implementation 'io.opentelemetry:opentelemetry-api'
// Spring AI - LLM providers
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-starter-model-anthropic'
implementation 'org.springframework.ai:spring-ai-starter-model-ollama'
// Spring AI - pgvector RAG
implementation 'org.springframework.ai:spring-ai-starter-vector-store-pgvector'
// Database (reactive + JDBC for pgvector)
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.postgresql:r2dbc-postgresql'
implementation 'org.postgresql:postgresql'
// JSON
implementation 'com.fasterxml.jackson.core:jackson-databind'
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
}
bootJar {
mainClass = 'com.example.support.Application'
}
tasks.named('test') {
useJUnitPlatform()
}
```
#### LlmService.java (abbreviated)
The core LLM call method that creates GenAI spans with semantic convention
attributes, records token usage and cost metrics, and handles content capture
with PII filtering. See the
[Custom LLM Instrumentation](#custom-llm-instrumentation) section for the full
walkthrough.
```java showLineNumbers title="src/main/java/com/example/support/llm/LlmService.java"
private LlmResponse generateOnce(
ChatModel chatModel, String providerName, String model,
String systemPrompt, String userPrompt, String stage,
List toolCallbacks
) {
String spanName = "gen_ai.chat " + model;
long start = System.nanoTime();
Span span = tracer.spanBuilder(spanName)
.setAttribute("gen_ai.operation.name", "chat")
.setAttribute("gen_ai.provider.name", providerName)
.setAttribute("gen_ai.request.model", model)
.setAttribute("gen_ai.request.temperature", config.temperature())
.setAttribute("gen_ai.request.max_tokens", (long) config.maxTokens())
.startSpan();
try (Scope ignored = span.makeCurrent()) {
// ... prompt building and content capture (see Custom LLM Instrumentation section)
ChatResponse response = chatModel.call(prompt);
// Extract token usage, cost, finish reason
// ... see Custom LLM Instrumentation section
span.setAttribute("gen_ai.response.model", responseModel);
span.setAttribute("gen_ai.usage.input_tokens", (long) inputTokens);
span.setAttribute("gen_ai.usage.output_tokens", (long) outputTokens);
span.setAttribute("gen_ai.usage.cost_usd", costUsd);
// Record metrics
tokenUsage.record(inputTokens, withTokenType(attrs, "input"));
tokenUsage.record(outputTokens, withTokenType(attrs, "output"));
operationDuration.record(duration, attrs);
costCounter.add(costUsd, attrs);
return new LlmResponse(content, responseModel, providerName,
inputTokens, outputTokens, costUsd, finishReason);
} catch (Exception e) {
span.setStatus(StatusCode.ERROR, e.getMessage());
errorCounter.add(1, /* ... */);
throw e;
} finally {
span.end();
}
}
```
#### SupportPipeline.java (abbreviated)
The 6-stage pipeline that orchestrates intent classification, RAG retrieval,
response generation, PII scrubbing, escalation routing, and metrics recording
under a single parent span. See the
[Pipeline Observability](#pipeline-observability) section for the full
walkthrough.
```java showLineNumbers title="src/main/java/com/example/support/pipeline/SupportPipeline.java"
private PipelineResult runPipeline(
String userMessage, UUID conversationId, List history
) {
long startNanos = System.nanoTime();
Span span = tracer.spanBuilder("support_conversation")
.setAttribute("support.conversation_id", conversationId.toString())
.startSpan();
try (Scope ignored = span.makeCurrent()) {
// 1. Classify intent (fast model)
IntentResult intent = intentClassifier.classify(userMessage);
span.setAttribute("support.intent", intent.intent().name());
// 2. Retrieve RAG context
var ragDocs = contextRetriever.retrieve(userMessage);
span.setAttribute("support.rag_matches", ragDocs.size());
// 3. Generate response (capable model)
LlmResponse response = responseGenerator.generate(
userMessage, intent, ragDocs, conversationHistory);
// 4. PII scrub
String content = piiFilter.scrub(response.content());
// 5. Check escalation
EscalationDecision escalation = escalationRouter.evaluate(intent, turns, 0);
// 6. Record domain metrics
// ... see Pipeline Orchestration section
return new PipelineResult(content, intent, escalation,
response.model(), response.provider(),
response.inputTokens(), response.outputTokens(),
response.costUsd(), conversationId);
} catch (Exception e) {
span.setStatus(StatusCode.ERROR, e.getMessage());
throw new RuntimeException("Pipeline failed: " + e.getMessage(), e);
} finally {
span.end();
}
}
```
#### compose.yml
See the [Docker Compose tab](#running-your-application) above for the full
`compose.yml` and `Dockerfile`.
#### otel-collector-config.yaml
See the [Docker Compose tab](#running-your-application) above for the full
collector configuration with all production essentials: `retry_on_failure`,
`debug` exporter, `health_check`/`zpages` extensions, `filter/noisy` processor,
and `memory_limiter`.
### References
- [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/)
- [Spring AI Documentation](https://docs.spring.io/spring-ai/reference/)
- [OpenTelemetry Java Agent](https://opentelemetry.io/docs/zero-code/java/agent/)
- [OpenTelemetry Java SDK](https://opentelemetry.io/docs/languages/java/)
- [Micrometer Tracing](https://micrometer.io/docs/tracing)
### Related Guides
- [LLM Observability](../llm-observability) - Python patterns for AI application
tracing
- [Rust LLM Observability](../rust-llm-observability) - Rust patterns with manual
GenAI instrumentation
- [Spring Boot Auto-Instrumentation](../../instrument/apps/auto-instrumentation/spring-boot.md)
- zero-code OpenTelemetry for Spring Boot web applications
- [Docker Compose Setup](../../instrument/collector-setup/docker-compose-example.md)
---
## ArgoCD Monitoring with OpenTelemetry - Metrics, Sync Status & Health
## ArgoCD
ArgoCD is a declarative GitOps continuous delivery tool for
Kubernetes that exposes Prometheus-format metrics from three core
components: the application controller (`:8082/metrics`), the API
server (`:8083/metrics`), and the repo server (`:8084/metrics`).
The OpenTelemetry Collector scrapes these endpoints using the
Prometheus receiver, collecting metrics across application sync
status, health state, reconciliation performance, Git request
latency, and gRPC request rates. This guide configures the
receiver, connects to an ArgoCD installation on Kubernetes, and
ships metrics to base14 Scout.
### Prerequisites
| Requirement | Minimum | Recommended |
| ---------------------- | ------- | ----------- |
| ArgoCD | 2.5 | 2.13+ |
| OTel Collector Contrib | 0.90.0 | 0.127+ |
| base14 Scout | Any | - |
Before starting:
- ArgoCD must be running on a Kubernetes cluster
- Metrics ports (8082, 8083, 8084) must be accessible from the
host or pod running the Collector
- ArgoCD exposes metrics by default - no additional configuration
is needed on the ArgoCD side
- OTel Collector installed - see
[Kubernetes Helm Setup](../../instrument/collector-setup/kubernetes-helm-setup.md)
### What You'll Monitor
- **Application State** (controller): sync status (synced,
out-of-sync), health status (healthy, degraded, missing,
unknown), app count per cluster and project, orphaned resource
count
- **Reconciliation** (controller): reconciliation duration per
destination cluster, kubectl execution count and duration,
kubectl request/response sizes
- **Cluster** (controller): cluster connection status, cluster
cache age, API resource objects count, cluster event totals
- **API Server**: ArgoCD version info, gRPC request rates and
totals by method, kubectl and Redis request performance
- **Repository Operations** (repo-server): Git request duration
and count by repo and request type (fetch, ls-remote), manifest
generation time, pending request count
- **Notifications** (optional): Go runtime and process metrics for
the notifications controller
Full metric list: port-forward each component and run
`curl -s http://localhost:{port}/metrics` against your ArgoCD
installation.
### Access Setup
ArgoCD exposes Prometheus metrics by default on all components.
No additional configuration is needed to enable the endpoints.
Each component exposes metrics on a dedicated port:
| Component | Default Port | Endpoint |
| ------------------------------- | ------------ | ---------- |
| argocd-application-controller | 8082 | `/metrics` |
| argocd-server | 8083 | `/metrics` |
| argocd-repo-server | 8084 | `/metrics` |
| argocd-applicationset-controller| 8080 | `/metrics` |
| argocd-notifications-controller | 9001 | `/metrics` |
#### Manifest Install
When installed via plain manifests (`kubectl apply`), ArgoCD
creates dedicated metrics services:
| Service Name | Port |
| ----------------------------------------- | ---- |
| `argocd-metrics` | 8082 |
| `argocd-server-metrics` | 8083 |
| `argocd-repo-server` | 8084 |
| `argocd-notifications-controller-metrics` | 9001 |
Verify the endpoints are working:
```bash showLineNumbers title="Verify access (manifest install)"
# Port-forward the application controller metrics
kubectl -n argocd port-forward svc/argocd-metrics 8082:8082
# In another terminal, check metrics
curl -s http://localhost:8082/metrics | head -20
# Verify a key ArgoCD metric exists
curl -s http://localhost:8082/metrics | grep argocd_app_info
```
#### Helm Install
When installed via the Helm chart (`argo/argo-cd`), metrics
services are **not created by default**. Enable them in your
Helm values:
```yaml showLineNumbers title="values.yaml"
controller:
metrics:
enabled: true
server:
metrics:
enabled: true
repoServer:
metrics:
enabled: true
notifications:
metrics:
enabled: true
```
Or pass the flags directly:
```bash showLineNumbers title="Helm install with metrics"
helm install argocd argo/argo-cd \
--namespace argocd --create-namespace \
--set controller.metrics.enabled=true \
--set server.metrics.enabled=true \
--set repoServer.metrics.enabled=true \
--set notifications.metrics.enabled=true
```
With metrics enabled, the Helm chart creates these services:
| Service Name | Port |
| ----------------------------------------- | ---- |
| `argocd-application-controller-metrics` | 8082 |
| `argocd-server-metrics` | 8083 |
| `argocd-repo-server-metrics` | 8084 |
| `argocd-notifications-controller-metrics` | 9001 |
Verify:
```bash showLineNumbers title="Verify access (Helm install)"
kubectl -n argocd port-forward svc/argocd-application-controller-metrics 8082:8082
curl -s http://localhost:8082/metrics | grep argocd_app_info
```
In Kubernetes, the Collector typically runs as a sidecar or
DaemonSet and accesses these ports via the cluster network.
No port-forwarding is needed in that case.
### Configuration
ArgoCD has three primary components that expose metrics. The
Collector configuration uses one scrape job per component.
#### Manifest Install
```yaml showLineNumbers title="config/otel-collector.yaml"
receivers:
prometheus:
config:
scrape_configs:
- job_name: argocd-application-controller
scrape_interval: 30s
metrics_path: /metrics
static_configs:
- targets:
- argocd-metrics.argocd.svc.cluster.local:8082
- job_name: argocd-server
scrape_interval: 30s
metrics_path: /metrics
static_configs:
- targets:
- argocd-server-metrics.argocd.svc.cluster.local:8083
- job_name: argocd-repo-server
scrape_interval: 30s
metrics_path: /metrics
static_configs:
- targets:
- argocd-repo-server.argocd.svc.cluster.local:8084
processors:
resource:
attributes:
- key: environment
value: ${env:ENVIRONMENT}
action: upsert
- key: service.name
value: ${env:SERVICE_NAME}
action: upsert
batch:
timeout: 10s
send_batch_size: 1024
# Export to base14 Scout
exporters:
otlphttp/b14:
endpoint: ${env:OTEL_EXPORTER_OTLP_ENDPOINT}
tls:
insecure_skip_verify: true
service:
pipelines:
metrics:
receivers: [prometheus]
processors: [resource, batch]
exporters: [otlphttp/b14]
```
#### Helm Install
When using the Helm chart with metrics services enabled, the
service names differ:
```yaml showLineNumbers title="config/otel-collector.yaml (Helm)"
receivers:
prometheus:
config:
scrape_configs:
- job_name: argocd-application-controller
scrape_interval: 30s
metrics_path: /metrics
static_configs:
- targets:
- argocd-application-controller-metrics.argocd.svc.cluster.local:8082
- job_name: argocd-server
scrape_interval: 30s
metrics_path: /metrics
static_configs:
- targets:
- argocd-server-metrics.argocd.svc.cluster.local:8083
- job_name: argocd-repo-server
scrape_interval: 30s
metrics_path: /metrics
static_configs:
- targets:
- argocd-repo-server-metrics.argocd.svc.cluster.local:8084
processors:
resource:
attributes:
- key: environment
value: ${env:ENVIRONMENT}
action: upsert
- key: service.name
value: ${env:SERVICE_NAME}
action: upsert
batch:
timeout: 10s
send_batch_size: 1024
exporters:
otlphttp/b14:
endpoint: ${env:OTEL_EXPORTER_OTLP_ENDPOINT}
tls:
insecure_skip_verify: true
service:
pipelines:
metrics:
receivers: [prometheus]
processors: [resource, batch]
exporters: [otlphttp/b14]
```
#### Environment Variables
```bash showLineNumbers title=".env"
ENVIRONMENT=your_environment
SERVICE_NAME=your_service_name
OTEL_EXPORTER_OTLP_ENDPOINT=https://.base14.io
```
#### Kubernetes Service Discovery
For dynamic scrape target discovery, use `kubernetes_sd_configs`
instead of static targets. This automatically discovers ArgoCD
metrics services:
```yaml showLineNumbers title="config/otel-collector.yaml (service discovery)"
receivers:
prometheus:
config:
scrape_configs:
- job_name: argocd
scrape_interval: 30s
kubernetes_sd_configs:
- role: endpoints
namespaces:
names:
- argocd
relabel_configs:
- source_labels: [__meta_kubernetes_service_name]
regex: "argocd-metrics|argocd-server-metrics|argocd-repo-server|argocd-application-controller-metrics|argocd-repo-server-metrics"
action: keep
- source_labels: [__meta_kubernetes_service_name]
target_label: argocd_component
```
This approach works with both manifest and Helm installations.
#### Filtering Metrics
ArgoCD components expose Go runtime and process metrics alongside
ArgoCD-specific metrics. The server also exposes standard gRPC
metrics. To collect only relevant metrics:
```yaml showLineNumbers title="config/otel-collector.yaml (filter)"
receivers:
prometheus:
config:
scrape_configs:
- job_name: argocd-application-controller
scrape_interval: 30s
metrics_path: /metrics
static_configs:
- targets:
- argocd-metrics.argocd.svc.cluster.local:8082
metric_relabel_configs:
- source_labels: [__name__]
regex: "argocd_.*"
action: keep
- job_name: argocd-server
scrape_interval: 30s
metrics_path: /metrics
static_configs:
- targets:
- argocd-server-metrics.argocd.svc.cluster.local:8083
metric_relabel_configs:
- source_labels: [__name__]
regex: "argocd_.*|grpc_server_.*"
action: keep
- job_name: argocd-repo-server
scrape_interval: 30s
metrics_path: /metrics
static_configs:
- targets:
- argocd-repo-server.argocd.svc.cluster.local:8084
metric_relabel_configs:
- source_labels: [__name__]
regex: "argocd_.*|grpc_server_.*"
action: keep
```
### Verify the Setup
Start the Collector and check for metrics within 60 seconds:
```bash showLineNumbers title="Verify metrics collection"
# Check Collector logs for successful scrape
kubectl logs -n \
| grep -i "argocd"
# Verify ArgoCD controller metrics directly
kubectl -n argocd port-forward svc/argocd-metrics 8082:8082
curl -s http://localhost:8082/metrics \
| grep argocd_app_info
# Check server metrics
kubectl -n argocd port-forward svc/argocd-server-metrics 8083:8083
curl -s http://localhost:8083/metrics \
| grep grpc_server_handled_total
```
### Troubleshooting
#### Connection refused on metrics port
**Cause**: Collector cannot reach ArgoCD pods at the configured
service address.
**Fix**:
1. Verify the metrics services exist:
`kubectl -n argocd get svc | grep metrics`
2. Service names differ between manifest and Helm installs - see
[Access Setup](#access-setup)
3. Check network policies - ArgoCD creates NetworkPolicy resources
that may block Collector access
4. Confirm the Collector pod can reach the argocd namespace
#### No application metrics (argocd_app_info missing)
**Cause**: No ArgoCD Applications have been created yet.
**Fix**:
1. ArgoCD only emits `argocd_app_*` metrics when at least one
Application CR exists
2. Create a sample Application to verify:
`kubectl -n argocd get applications`
3. Once an Application is synced, `argocd_app_info`,
`argocd_app_reconcile`, and `argocd_cluster_*` metrics appear
#### No metrics appearing in Scout
**Cause**: Metrics are collected but not exported.
**Fix**:
1. Check Collector logs for export errors:
`kubectl logs `
2. Verify `OTEL_EXPORTER_OTLP_ENDPOINT` is set correctly
3. Confirm the pipeline includes both the receiver and exporter
#### Partial metrics - only some components reporting
**Cause**: Not all ArgoCD component endpoints are configured in
the scrape config.
**Fix**:
1. Verify all three scrape jobs are present in the Collector config
2. Check that service names match your install method (manifest vs
Helm)
3. Verify each service is reachable:
`kubectl -n argocd port-forward svc/ :`
#### Helm install - no metrics services found
**Cause**: The Helm chart does not create metrics services by
default.
**Fix**:
1. Enable metrics in Helm values:
`controller.metrics.enabled=true`,
`server.metrics.enabled=true`,
`repoServer.metrics.enabled=true`
2. Upgrade: `helm upgrade argocd argo/argo-cd -n argocd -f values.yaml`
3. Verify: `kubectl -n argocd get svc | grep metrics`
### FAQ
#### Does this work with ArgoCD installed via Helm?
Yes. Enable metrics services in the Helm values by setting
`controller.metrics.enabled`, `server.metrics.enabled`, and
`repoServer.metrics.enabled` to `true`. The Helm chart creates
dedicated metrics services with different names than the manifest
install - see [Access Setup](#access-setup) for the full mapping.
Verify your service names with `kubectl -n argocd get svc`.
#### How do I monitor ArgoCD in a multi-cluster setup?
Each ArgoCD instance manages one or more target clusters. Deploy
one Collector config per ArgoCD control plane. The
`argocd_cluster_info` metric includes a `server` label identifying
the managed cluster, and `argocd_cluster_connection_status` reports
whether each cluster is reachable.
#### Which component exposes sync status metrics?
The application controller (port 8082) emits all application state
metrics. The `argocd_app_info` metric includes `sync_status` and
`health_status` labels:
```text
argocd_app_info{name="guestbook",sync_status="OutOfSync",health_status="Missing",...} 1
```
The server (port 8083) only exposes API/gRPC request metrics and
ArgoCD version info (`argocd_info`).
#### Can I use Kubernetes service discovery instead of static targets?
Yes. See the
[Kubernetes Service Discovery](#kubernetes-service-discovery)
section above. This approach uses `kubernetes_sd_configs` with
`relabel_configs` to match ArgoCD metrics services automatically
and works with both manifest and Helm installations.
#### What is the difference between manifest and Helm service names?
The service names for metrics differ between install methods:
| Component | Manifest | Helm (metrics enabled) |
| ------------------- | ----------------------------------------- | ----------------------------------------- |
| Application Controller | `argocd-metrics` | `argocd-application-controller-metrics` |
| Server | `argocd-server-metrics` | `argocd-server-metrics` |
| Repo Server | `argocd-repo-server` | `argocd-repo-server-metrics` |
| Notifications | `argocd-notifications-controller-metrics` | `argocd-notifications-controller-metrics` |
Always verify with `kubectl -n argocd get svc` to confirm the
service names in your environment.
#### How do I monitor ArgoCD with Prometheus?
ArgoCD exposes Prometheus-format metrics on dedicated endpoints
(ports 8082, 8083, 8084) for the application controller, API
server, and repo server. Scrape these endpoints using the
OpenTelemetry Collector's Prometheus receiver or a native
Prometheus ServiceMonitor. See
[Configuration](#configuration) for complete scrape configs.
#### What metrics does ArgoCD expose?
ArgoCD exposes metrics across five categories:
`argocd_app_info` (sync and health status per application),
`argocd_app_reconcile` (reconciliation duration),
`argocd_git_request_total` (Git fetch/ls-remote counts and
duration), `argocd_cluster_connection_status` (managed cluster
connectivity), and `grpc_server_handled_total` (API request
rates). Run `curl http://localhost:8082/metrics` against a
running instance for the full list.
#### What is the default ArgoCD metrics endpoint?
The application controller exposes metrics at `:8082/metrics`,
the API server at `:8083/metrics`, and the repo server at
`:8084/metrics`. These are enabled by default in manifest
installs. For Helm installs, set `controller.metrics.enabled`,
`server.metrics.enabled`, and `repoServer.metrics.enabled` to
`true`.
### What's Next?
- **Create Dashboards**: Explore pre-built dashboards or build
your own. See
[Create Your First Dashboard](../create-your-first-dashboard.md)
- **Monitor More Components**: Add monitoring for
[etcd](../../instrument/component/etcd.md),
[Consul](../../instrument/component/consul.md),
and other components
- **Fine-tune Collection**: Use `metric_relabel_configs` to focus on
application sync and health metrics for production alerting
### Related Guides
- [OTel Collector Configuration](../../instrument/collector-setup/otel-collector-config.md)
- Advanced collector configuration
- [Kubernetes Helm Setup](../../instrument/collector-setup/kubernetes-helm-setup.md)
- Production deployment on Kubernetes
- [Docker Compose Setup](../../instrument/collector-setup/docker-compose-example.md)
- Run the Collector locally
- [Creating Alerts](../creating-alerts-with-logx.md)
- Alert on ArgoCD metrics
- [etcd Monitoring](../../instrument/component/etcd.md)
- Often co-deployed with ArgoCD on Kubernetes
---
## GitHub Actions Observability with OpenTelemetry
CI/CD pipelines are critical infrastructure. Builds slow down over weeks,
flaky tests waste developer time, and when a pipeline breaks, diagnosing the
root cause means clicking through GitHub's UI one run at a time.
The **Scout OpenTelemetry CI/CD Action** solves this by exporting your GitHub
Actions workflow runs as OpenTelemetry traces. Each workflow becomes a trace,
each job becomes a child span, and each step becomes a span within its job.
You get the same structured observability for your pipelines that you already
have for your applications.
### Time to Complete
10-15 minutes
### What You'll Accomplish
- Export GitHub Actions workflows as OpenTelemetry traces to Scout
- Visualize pipeline execution as a trace waterfall (workflow → jobs → steps)
- Identify slow steps and failure patterns across your CI/CD pipelines
- Set up monitoring without modifying your existing workflows
### How It Works
The action runs after your workflow completes and uses the GitHub API to
fetch the full run data - jobs, steps, timestamps, and outcomes. It converts
this data into OpenTelemetry traces following the
[CICD semantic conventions](https://opentelemetry.io/docs/specs/semconv/attributes-registry/cicd/)
and exports them to your OTLP endpoint.
The resulting trace hierarchy looks like this:
```text
Workflow Run (root span)
├── Job: build (child span)
│ ├── Step: Checkout code
│ ├── Step: Install dependencies
│ ├── Step: Run tests
│ └── Step: Build artifact
└── Job: deploy (child span)
├── Step: Download artifact
└── Step: Deploy to production
```
Each span includes timing data, status (success/failure), and GitHub metadata
like the commit SHA, branch, actor, and run URL.
### Prerequisites
- A Scout account with OAuth2 credentials (client ID, client secret, and
tenant name)
- A GitHub repository with one or more workflows
### Option A: Workflow Run Trigger (Recommended)
This approach creates a dedicated monitoring workflow that triggers
automatically after any workflow completes. It requires no changes to your
existing workflows.
Create a new file at `.github/workflows/otel-export.yml`:
```yaml
name: Export CI/CD Traces to Scout
on:
workflow_run:
workflows: ["*"]
types:
- completed
jobs:
otel-export:
runs-on: ubuntu-latest
steps:
- name: Export workflow trace
uses: base-14/otel-cicd-action@v1.0.0
with:
otlpEndpoint: ${{ secrets.SCOUT_OTLP_ENDPOINT }}
otelServiceName: my-repo-ci
githubToken: ${{ secrets.GITHUB_TOKEN }}
runId: ${{ github.event.workflow_run.id }}
tokenUrl: >-
https://id.b14.dev/realms/${{ secrets.SCOUT_TENANT }}/protocol/openid-connect/token
appName: ${{ secrets.SCOUT_CLIENT_ID }}
apiKey: ${{ secrets.SCOUT_CLIENT_SECRET }}
audience: b14collector
```
#### Required Secrets
Add these secrets to your repository under **Settings → Secrets and
variables → Actions**:
| Secret | Description |
| --------------------- | --------------------------------------------- |
| `SCOUT_OTLP_ENDPOINT` | Your Scout OTLP endpoint (provided via email) |
| `SCOUT_TENANT` | Your Scout tenant name |
| `SCOUT_CLIENT_ID` | OAuth2 client ID from Scout |
| `SCOUT_CLIENT_SECRET` | OAuth2 client secret from Scout |
`GITHUB_TOKEN` is provided automatically by GitHub Actions.
#### How It Works
The `workflow_run` event fires after any workflow in your repository
completes. The `runId` input points to the completed workflow's run ID
(`github.event.workflow_run.id`), so the action fetches and exports that
run's data - not its own.
### Option B: In-Workflow Job
If you prefer to keep the export step within a specific workflow, add it as a
final job that runs regardless of whether previous jobs succeed or fail.
```yaml
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build
run: npm run build
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy
run: echo "Deploying..."
otel-export:
needs: [build, deploy]
if: always()
runs-on: ubuntu-latest
steps:
- name: Export workflow trace
uses: base-14/otel-cicd-action@v1.0.0
with:
otlpEndpoint: ${{ secrets.SCOUT_OTLP_ENDPOINT }}
otelServiceName: my-repo-ci
githubToken: ${{ secrets.GITHUB_TOKEN }}
tokenUrl: >-
https://id.b14.dev/realms/${{ secrets.SCOUT_TENANT }}/protocol/openid-connect/token
appName: ${{ secrets.SCOUT_CLIENT_ID }}
apiKey: ${{ secrets.SCOUT_CLIENT_SECRET }}
audience: b14collector
```
Key details:
- **`needs: [build, deploy]`** - waits for all jobs to finish before
exporting
- **`if: always()`** - ensures the export runs even if a previous job fails,
so you capture traces for broken builds too
- **`runId`** is omitted - it defaults to the current workflow run ID
### Configuration Reference
| Input | Required | Default | Description |
| ----------------- | -------- | ------------------ | --------------------------------------------------------------------- |
| `otlpEndpoint` | Yes | - | OTLP endpoint to export traces to |
| `otlpHeaders` | No | `""` | Additional HTTP headers for the OTLP endpoint |
| `otelServiceName` | No | `""` | OpenTelemetry service name for the exported traces |
| `githubToken` | No | `secrets.GITHUB_TOKEN` | Repository token with workflow read permissions |
| `runId` | No | `env.GITHUB_RUN_ID` | Workflow run ID to export (defaults to the current run) |
| `extraAttributes` | No | - | Extra resource attributes to add to each span |
| `tokenUrl` | No | `""` | OAuth2 token endpoint URL for `client_credentials` flow |
| `appName` | No | `""` | OAuth2 client ID (application name) for `client_credentials` flow |
| `apiKey` | No | `""` | OAuth2 client secret (API key) for `client_credentials` flow |
| `audience` | No | `""` | OAuth2 audience for `client_credentials` flow |
The action also produces one output:
- **`traceId`** - the OpenTelemetry trace ID of the root span, which you can
use to link directly to the trace in Scout
### Viewing Traces in Scout
Once the action runs, your workflow traces appear in Scout's trace explorer - traceX.
#### Finding Your Traces
1. Open **traceX** in Scout
2. Filter by the `otelServiceName` you configured (e.g., `my-repo-ci`)
3. You'll see one trace per workflow run, with the workflow name as the root
span
#### Reading the Trace Waterfall

The trace waterfall shows the full execution timeline:
- **Root span** - the workflow run, showing total duration and final status
- **Job spans** - each job appears as a child of the root span, with its own
start time and duration
- **Step spans** - each step within a job is a child of the job span
Parallel jobs appear side by side in the waterfall, making it easy to see
which jobs ran concurrently and which ran sequentially. Failed steps are
highlighted, so you can immediately spot where a pipeline broke.
#### Useful Filters
- **`service.name`** - filter by repository or project
- **Status** - filter for failed traces to see broken builds
- **Duration** - sort by duration to find your slowest pipelines
### Verification
1. Trigger a workflow run in your repository
2. Open **traceX** in Scout and filter by your `otelServiceName`
3. Confirm a new trace appears with the workflow name as the root span
### Practical Example: Finding Slow Steps
A common use case is identifying which steps slow down your pipeline over
time. Here's how to use traces in Scout to find bottlenecks.
#### Scenario
Your CI pipeline used to complete in 3 minutes but now takes 8 minutes. You
want to find out which step is responsible.
#### Approach
1. Open a recent trace for the workflow in Scout
2. Look at the trace waterfall - the longest spans immediately stand out
3. Compare with an older trace to see what changed
#### Common Bottlenecks
| Symptom | Likely cause | Fix |
| --------------------------- | ------------------------------------------ | -------------------------------------------- |
| Long checkout step | Large repository or LFS objects | Use `fetch-depth: 1` for shallow clone |
| Slow dependency install | No caching configured | Add dependency caching (actions/cache) |
| Long test suite | Tests running sequentially | Parallelize test jobs or use test sharding |
| Slow Docker build | No layer caching | Use Docker build cache or registry caching |
| Long deployment step | Waiting for health checks or approvals | Review timeout and health check configuration |
By comparing traces across multiple runs, you can also spot regressions —
a step that was 30 seconds last week but is now 2 minutes points to a
specific change worth investigating.
### Troubleshooting
#### Action Fails with 401 Unauthorized
This typically means the Scout OAuth2 credentials are incorrect or the token
request is failing. Verify your credentials by requesting a token directly:
```bash
curl -X POST "$SCOUT_TOKEN_URL" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=$CLIENT_ID&client_secret=$API_KEY&audience=$AUDIENCE"
```
If this returns a valid access token, your credentials are correct and the
issue is likely in how the secrets are configured in your repository. If you
get an error, double-check the `tokenUrl`, `appName`, `apiKey`, and
`audience` values in your Scout account.
#### No Traces Appearing in Scout
1. **Check the action logs** - expand the "Export workflow trace" step in the
GitHub Actions UI to see any error messages
2. **Verify credentials** - ensure `SCOUT_TENANT`, `SCOUT_CLIENT_ID`, and
`SCOUT_CLIENT_SECRET` are set correctly in repository secrets
3. **Check the endpoint** - confirm `otlpEndpoint` matches the Scout
endpoint provided to you during onboarding
4. **Verify network access** - GitHub-hosted runners need outbound HTTPS
access to both the Scout API and the identity provider
#### Workflow Run Trigger Not Firing
The `workflow_run` event only triggers for workflows on the default branch.
If you're testing on a feature branch, use Option B (in-workflow job)
instead, or merge the `otel-export.yml` file to your default branch first.
#### Traces Missing Steps
If some steps don't appear in the trace, they may have been skipped by
conditional logic (`if:` conditions). Skipped steps are not included in
the GitHub API response and won't appear as spans.
### What's Next?
- [Create Your First Dashboard](../create-your-first-dashboard.md) - build
dashboards to visualize CI/CD metrics over time
- [Creating Alerts with LogX](../creating-alerts-with-logx.md) - set up alerts
for pipeline failures or duration regressions
- [Quick Start](../quick-start.md) - get Scout set up for application
observability alongside your CI/CD traces
---
## CI/CD Observability - Monitor Your Pipelines with OpenTelemetry
## CI/CD Observability
Monitor your CI/CD pipelines with OpenTelemetry and
base14 Scout. Trace workflow execution, track deployment
health, and identify bottlenecks across your delivery
pipelines.
### Guides
- **[GitHub Actions Observability](./github-actions-observability.md)**
- Export GitHub Actions workflows as OpenTelemetry
traces. Identify slow steps, track failure patterns,
and gain full visibility into your CI pipelines.
- **[ArgoCD Monitoring](./argocd.md)** - Collect ArgoCD
Prometheus metrics with the OpenTelemetry Collector.
Monitor application sync status, health,
reconciliation, and Git operations.
---
## Create Your First Dashboard
This guide walks you through creating your first dashboard in Scout. You'll
learn how to explore available metrics, build queries correctly, and visualize
your telemetry data in Grafana.
### Time to Complete
15-20 minutes
### What You'll Accomplish
- Identify a metric to monitor
- Build a correct query in Grafana
- Save and organize your first dashboard
### Prerequisites
- Access to Scout dashboard with Grafana
- Telemetry data flowing from your instrumented applications
- Basic understanding of your services and environments
### Step 1: Start from the Metrics Collected Dashboard
Before creating a dashboard, explore the pre-built **Metrics Collected**
dashboard to understand what data is available.
1. Open the **Metrics Collected** dashboard
2. Select:
- **Environment** - Choose the desired environment (e.g., production,
staging)
- **Service Name** - Pick the service you want to monitor
3. Find the **metric you want to visualize** (from the list or search)
4. Use this dashboard to inspect:
- **Last 5 values** of the metric
- **Resource Attributes** and **Attributes** - Note these for filtering
later
> **Note**: Record the metric name and relevant attributes. You'll use these
> when writing queries later.
### Step 2: Create a New Dashboard and Add a Panel
1. Go to **Dashboards → New → New Dashboard**
2. Add a **New Panel**
### Step 3: Configure Data Source and Query Settings
#### Always Select DS_ScoutAltCH for Data Source
Always choose **DS_ScoutAltCH** as your data source.
#### Query Settings by Table
Configure the query settings based on which table you're using. The key
difference is the **Timestamp Column** setting.
| Table | Database | Column Type | Timestamp Column | Date Column |
| ------------------------ | -------------- | ----------- | ---------------- | ----------- |
| `otel_metrics_sum` | `` | `DateTime64` | `TimeUnix` | Leave default |
| `otel_metrics_gauge` | `` | `DateTime64` | `TimeUnix` | Leave default |
| `otel_metrics_histogram` | `` | `DateTime64` | `TimeUnix` | Leave default |
| `otel_metrics_summary` | `` | `DateTime64` | `TimeUnix` | Leave default |
| `otel_logs` | `` | `DateTime64` | `TimestampTime` | Leave default |
| `otel_traces` | `` | `DateTime64` | `Timestamp` | Leave default |
> **Important**: For metrics tables, use `TimeUnix`. For logs, use
> `TimestampTime`. For traces, use `Timestamp`.
### Step 4: Write Your Query
Use this template for your query:
```sql
SELECT ...
FROM $table
WHERE $timeFilter
AND ServiceName = ''
AND = ''
AND ResourceAttributes['environment'] = ''
```
#### Required WHERE Conditions
Each query **must include** the following WHERE conditions:
1. **`$timeFilter`** - Grafana's time range filter (always required)
2. **`ServiceName = 'value'`** - The service you're monitoring
3. **` = 'value'`** - Use `MetricName` for metrics/logs,
`SpanName` for traces
4. **`ResourceAttributes['environment'] = 'value'`** - The environment (e.g.,
production, staging)
#### Time Series Configuration
For time series queries, make sure the **Step** field below the query is set
to **`1m`** (1 minute).
#### Adding More Filters
You can add more filters based on `Attributes` or `ResourceAttributes` for
deeper insights:
```sql
AND Attributes['http.method'] = 'GET'
AND ResourceAttributes['host.name'] = 'server-01'
```
### Step 5: Example Panel Query
Here's a complete example for a CPU usage metric (gauge):
```sql
SELECT
$timeSeries AS t,
avg(Value) AS cpu_usage
FROM otel_metrics_gauge
WHERE $timeFilter
AND ServiceName = 'backend-service'
AND MetricName = 'system.cpu.usage'
AND ResourceAttributes['environment'] = 'production'
GROUP BY t
ORDER BY t
```
### Step 6: Save and Organize
Once your panel displays data correctly:
1. Click **Apply** to save the panel
2. Click **Save dashboard** (disk icon in top right)
3. Give your dashboard a descriptive name
4. Save it in the **Drafts** folder initially
5. Review the dashboard with your team or QA
6. Move it to the appropriate folder once approved
#### Recommended Folder Organization
- **Drafts** - Work-in-progress dashboards
- **Production** - Approved production monitoring dashboards
- **Development** - Development environment dashboards
- **Team-specific folders** - Organized by team or service
### Verification
Use this checklist to confirm your dashboard is ready:
Before finalizing your dashboard, verify:
- [ ] Data Source = `DS_ScoutAltCH`
- [ ] Correct table and timestamp settings
- [ ] `$timeFilter` included in query
- [ ] Step = `1m` for time series
- [ ] Required filters: ServiceName, MetricName/SpanName, environment
- [ ] Dashboard saved under correct folder
### Best Practices
#### Query Performance
- Always include `$timeFilter` to limit data scanning
- Add ServiceName filter to reduce query scope
- Use specific metric names rather than wildcards
#### Dashboard Organization
- Group related panels together
- Use descriptive panel titles and descriptions
- Keep dashboards focused on specific services or use cases
### Troubleshooting
#### No Data Displayed
1. Verify the time range includes data
2. Check that ServiceName matches exactly (case-sensitive)
3. Confirm MetricName is spelled correctly
4. Verify environment filter matches your data
#### Query Errors
1. Ensure `$timeFilter` is included in WHERE clause
2. Verify table name matches query settings
3. Check timestamp column is correctly configured
4. Validate SQL syntax (commas, quotes, brackets)
### Next Steps
- [Creating Alerts with LogX](creating-alerts-with-logx.md) - Set up alerts
based on your dashboard queries
- [Quick Start](quick-start.md) - Get Scout set up and sending data
### References
- [Grafana Dashboard Documentation](https://grafana.com/docs/grafana/latest/dashboards/)
\- Learn about dashboard basics and panel types
- [Time Series Panel](https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/time-series/)
\- Understanding time series visualizations in Grafana
---
## Creating Alerts with LogX
LogX provides a streamlined workflow for creating alerts based on your log
queries. This guide walks you through the process of exporting an alert query
from LogX and setting it up in Grafana's alerting system.
### Time to Complete
15-20 minutes
### What You'll Accomplish
- Generate an alert-ready LogX query
- Add the query to a Grafana panel
- Create and configure a Grafana alert rule
### Prerequisites
- Access to LogX and Grafana in Scout
- Logs flowing into Scout for the service you want to alert on
- Permissions to create dashboards and alert rules
### Overview
The alert creation process involves three main steps:
1. **Explore Alert Query from LogX**: Use the "Explore Alert Query" button to
generate a ClickHouse query based on your current filters and search
criteria.
2. **Add Query to Dashboard**: Create or add the query to a Grafana dashboard
with the correct datasource configuration.
3. **Create Alert Rule**: Configure the alert rule from the dashboard panel
with thresholds and notification settings.
### Step 1: Explore Alert Query from LogX
#### Generate the Query
1. Open the **LogX** application in Scout
2. Configure your log filters:
- Select the service you want to monitor
- Add resource attribute filters (e.g., `host.name`,
`deployment.environment`)
- Add log attribute filters as needed
- Apply body search terms or regex patterns
3. Verify your filters are showing the logs you want to alert on
4. Click the **"Explore Alert Query"** button in the header controls
#### Understanding the Generated Query
The exported query is a ClickHouse SQL statement that:
- Counts log entries matching your filters
- Includes all active filters (resource attributes, log attributes, body
search)
- Returns a single numeric value (count of matching logs)
Example query structure:
```sql
SELECT count(*) as value
FROM $table
WHERE $timeFilter
AND ServiceName = 'api-service'
AND ResourceAttributes['environment'] = 'production'
AND ResourceAttributes['host.name'] IN ('server-1', 'server-2')
```
#### Copy the Query
1. In the **Alert Query Dialog**, review the generated SQL query
2. Click the **"Copy Explore Query"** button to copy it to your clipboard
3. Close the dialog
### Step 2: Add Query to a Dashboard
#### Create or Open a Dashboard
1. Navigate to **Dashboards**
2. Either:
- Create a new dashboard by clicking **"New Dashboard"**
- Open an existing dashboard where you want to add the alert
#### Add a New Panel
1. Click **"Add"** → **"Visualization"** to add a new panel
2. Configure the panel settings:
#### Configure the Datasource
In the query editor:
1. **Datasource**: Select `DS_ScoutAltCH` (Scout Altinity ClickHouse
datasource)
2. Click on the **Query Options** or **Settings** (gear icon)
3. Configure the following settings:
- **Database**: Enter your organization name (e.g., `acme-corp`)
- **Table**: Enter `otel_logs`
- **Timestamp Type**: Select `DateTime64`
- **Timestamp Column**: Enter `Timestamp`
#### Add Your Query
1. Switch to **SQL Editor** mode (toggle button or "Edit SQL" option)
2. Paste the alert query you copied from LogX
#### Configure the Visualization
1. Set panel title (e.g., "Production API Errors")
2. Configure value options, thresholds, and colors as needed
3. Click **"Apply"** to save the panel
#### Save the Dashboard
1. Click **"Save dashboard"** (disk icon)
2. Give your dashboard a meaningful name
3. Optionally add it to a folder
4. Click **"Save"**
### Step 3: Create Alert Rule
Now that you have a dashboard panel with your log query, you're ready to
create the alert rule.
1. On your dashboard, locate the panel you just created
2. Click the **panel title** or **three dots (⋮)** menu
3. Select **"More..."** → **"New alert rule"**
That's it! Now you can follow the comprehensive
[Creating Alerts in Grafana](https://grafana.com/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule/)
guide to configure your alert rule, set thresholds, configure notifications, and
test your alert.
The general alerting guide covers:
- Setting alert rule names and descriptions
- Defining query conditions and thresholds
- Configuring evaluation behavior and timing
- Adding alert details and templates
- Setting up notifications and contact points
- Testing and troubleshooting alerts
- Best practices for alerting
### Verification
1. Use **Preview alerts** to confirm the rule evaluates successfully
2. Set a short evaluation interval temporarily to validate behavior
3. Trigger a known log condition and verify the alert transitions to Firing
### Best Practices
#### Query Optimization
1. **Use Specific Filters**: Narrow down logs to reduce query load
- Filter by service name
- Use environment filters
- Apply relevant attribute filters
2. **Avoid Wildcards**: Be specific in your search patterns
- Good: `ServiceName = 'api-service'`
- Avoid: `ServiceName LIKE '%api%'`
### Troubleshooting
If the alert doesn't fire as expected:
1. Confirm the panel query returns a non-zero value in the time range
2. Verify the table is `otel_logs` and the timestamp column is `Timestamp`
3. Ensure the rule uses the same datasource and query as the panel
4. Check that the evaluation interval matches your log volume
### Next Steps
- [Dashboards and Alerts](../operate/dashboards-and-alerts.md) - General
dashboard and alerting overview
- [Grafana Alerting Documentation](https://grafana.com/docs/grafana/latest/alerting/)
\- Official Grafana alerting guide
---
## Creating Alerts with pgX
pgX provides a streamlined workflow for creating alerts based on the panels
in the app. This guide walks you through exporting an alert query from a pgX
panel and setting it up in Grafana's alerting system.
:::note Running this in production
pgX is the PostgreSQL monitoring app in base14 Scout.
[Check out base14 pgX](https://base14.io/scout/pgx).
:::
### Time to Complete
15-20 minutes
### What You'll Accomplish
- Generate an alert-ready pgX query from any time-series or stat panel
- Add the query to a Grafana panel
- Create and configure a Grafana alert rule
### Prerequisites
- Access to pgX and Grafana in Scout
- pgX configured and showing data for the database you want to alert on
- Permissions to create dashboards and alert rules
### Overview
The alert creation process involves three main steps:
1. **Show Alert Query from pgX**: Use the panel's **Show alert query** menu
item to generate a ClickHouse query based on the panel's current filters
and metric.
2. **Add Query to Dashboard**: Create or add the query to a Grafana dashboard
with the correct datasource configuration.
3. **Create Alert Rule**: Configure the alert rule from the dashboard panel
with thresholds and notification settings.
### Step 1: Show Alert Query from pgX
#### Generate the Query
1. Open the **pgX** application in Scout
2. Configure your panel filters at the top of the page:
- Select the **Environment** and **Cluster** you want to monitor
- On the Queries tab, optionally narrow by Database, User, or Query Type
3. Pick the panel you want to alert on. The **Show alert query** option is
available on time-series and stat panels (the three-dot menu in the
top-right corner of each panel)
4. Click the panel's three-dot menu, then click **Show alert query**
#### Understanding the Generated Query
The exported query is a ClickHouse SQL statement that:
- Reads the same metric the panel reads
- Carries over the panel's environment, cluster, database, user, and query-type
filters as literal values
- Uses Vertamedia macros for the time range, table, and bucket alignment so
the alert engine can re-evaluate it against any time window
Example query structure:
```sql
SELECT
$timeSeries as t,
anyLast(Value)
FROM $table
WHERE $timeFilter
AND MetricName = 'pg_up'
AND ServiceName in ('pgdashex')
AND Attributes['cluster'] = 'prod-db'
GROUP BY t
ORDER BY t
```
The macros are expanded by the ClickHouse datasource at evaluation time:
| Macro | Expands to |
|-------|------------|
| `$timeFilter` | `TimeUnix >= toDateTime64(...) AND TimeUnix <= toDateTime64(...)` |
| `$table` | The configured database and table (e.g. `acme-corp.otel_metrics_gauge`) |
| `$timeSeries` | A bucketed millisecond timestamp aligned to the evaluation interval |
#### Copy the Query
1. In the **Alert Query** dialog, review the generated SQL query
2. Click the **"Copy Query"** button to copy it to your clipboard
3. Close the dialog
### Step 2: Add Query to a Dashboard
#### Create or Open a Dashboard
1. Navigate to **Dashboards**
2. Either:
- Create a new dashboard by clicking **"New Dashboard"**
- Open an existing dashboard where you want to add the alert
#### Add a New Panel
1. Click **"Add"** → **"Visualization"** to add a new panel
2. Configure the panel settings:
#### Configure the Datasource
In the query editor:
1. **Datasource**: Select `DS_ScoutAltCH` (Scout Altinity ClickHouse
datasource)
2. Click on the **Query Options** or **Settings** (gear icon)
3. Configure the following settings:
- **Database**: Enter your organization name (e.g., `acme-corp`)
- **Table**: Enter `otel_metrics_gauge`
- **Timestamp Type**: Select `DateTime64`
- **Timestamp Column**: Enter `TimeUnix`
#### Add Your Query
1. Switch to **SQL Editor** mode (toggle button or "Edit SQL" option)
2. Paste the alert query you copied from pgX
#### Configure the Visualization
1. Set panel title (e.g., "PostgreSQL Up")
2. Configure value options, thresholds, and colors as needed
3. Click **"Apply"** to save the panel
#### Save the Dashboard
1. Click **"Save dashboard"** (disk icon)
2. Give your dashboard a meaningful name
3. Optionally add it to a folder
4. Click **"Save"**
### Step 3: Create Alert Rule
Now that you have a dashboard panel with your pgX query, you're ready to
create the alert rule.
1. On your dashboard, locate the panel you just created
2. Click the **panel title** or **three dots (⋮)** menu
3. Select **"More..."** → **"New alert rule"**
That's it! Now you can follow the comprehensive
[Creating Alerts in Grafana](https://grafana.com/docs/grafana/latest/alerting/alerting-rules/create-grafana-managed-rule/)
guide to configure your alert rule, set thresholds, configure notifications, and
test your alert.
The general alerting guide covers:
- Setting alert rule names and descriptions
- Defining query conditions and thresholds
- Configuring evaluation behavior and timing
- Adding alert details and templates
- Setting up notifications and contact points
- Testing and troubleshooting alerts
- Best practices for alerting
### Verification
1. Use **Preview alerts** to confirm the rule evaluates successfully
2. Set a short evaluation interval temporarily to validate behavior
3. Trigger a known database condition and verify the alert transitions to
Firing (e.g., stop the monitored database to test a `pg_up` alert)
### Best Practices
#### Query Scoping
1. **Pick a specific cluster**: Always set the cluster filter in pgX before
exporting — alerts on the implicit "all clusters" set are usually noisy
2. **Pick a specific database/user/query** when alerting on per-query metrics
(Queries tab drawer) so the alert tracks one concrete thing
### Troubleshooting
If the alert doesn't fire as expected:
1. Confirm the panel query returns a non-zero value in the chosen time range
2. Verify the table is `otel_metrics_gauge` and the timestamp column is
`TimeUnix` with type `DateTime64`
3. Ensure the rule uses the same datasource and query as the panel
4. Use an evaluation interval of at least 5 minutes — narrower windows can
give zero data points
### Next Steps
- [Dashboards and Alerts](../operate/dashboards-and-alerts.md) - General
dashboard and alerting overview
- [Grafana Alerting Documentation](https://grafana.com/docs/grafana/latest/alerting/)
\- Official Grafana alerting guide
---
## Flutter Mobile Observability
Mobile apps face observability challenges that backend services do not: devices
run on battery, connectivity is unreliable, and the OS can kill your app at any
time. OpenTelemetry gives you traces, metrics, and error data from your Flutter
app exported to any OTLP-compatible collector.
This guide walks you through adding the `scout_flutter` SDK, initializing it
in your app, and verifying that spans reach your collector.
:::note Running this in production
Storing and querying these sessions at production volume is what base14 Scout
does. [Check out Scout RUM](https://base14.io/scout/rum).
:::
### Time to Complete
15-20 minutes
### What You'll Accomplish
- Add the `scout_flutter` SDK to your Flutter project
- Initialize zero-config RUM in your app entry point
- Verify spans are flowing to your collector
### Prerequisites
- **Flutter SDK 3.7.0+** and **Dart SDK 3.7.0+** installed
- A running **OpenTelemetry Collector** with an OTLP endpoint
(see [Docker Compose Setup](../instrument/collector-setup/docker-compose-example.md)
for local development)
- An existing Flutter app to instrument
### Telemetry Architecture
Before instrumenting your app, decide how telemetry gets from the device to
Scout. There are two deployment models.
#### Recommended: OTel Collector with API Gateway

Devices send OTLP data to a load balancer or API gateway, which handles
authentication and rate limiting. The gateway forwards traffic to an OTel
collector that applies server-side sampling before exporting to Scout.
#### Alternative: Direct to Scout
Devices send OTLP data directly to the Scout ingestion endpoint, authenticating
with OAuth tokens that the app manages.
#### Comparison
| | Collector + API Gateway | Direct to Scout |
| :--- | :--- | :--- |
| **Authentication** | API gateway handles auth centrally - app only needs an API key or static token | App must manage OAuth token lifecycle (acquire, refresh, retry on 401) |
| **Credential security** | Credentials stay on your infra - app ships a lightweight key that the gateway validates | OAuth client secret must be embedded or fetched at runtime - risk of extraction from APK/IPA |
| **Rate limiting** | API gateway enforces per-device or per-app rate limits - protects backend from traffic spikes | No rate limiting - a buggy release can flood Scout with telemetry |
| **Server-side sampling** | OTel collector applies tail sampling (e.g. keep all errors, sample 10% of healthy spans) - reduces cost without losing signal | All sampling must happen on-device - you lose the ability to make sampling decisions with full context |
| **Buffering and retry** | Collector buffers and retries on export failure - device fire-and-forget | App must handle retry logic and local buffering if Scout is unreachable |
| **Schema evolution** | Collector processors can rename attributes, drop PII, or add resource attributes without app updates | Any schema change requires an app release and user update |
| **Network efficiency** | Gateway can terminate TLS at the edge, compress, and batch - lower overhead per device | Each device opens its own TLS connection to Scout - more overhead at scale |
| **Operational cost** | Requires running a collector and gateway (but these are standard infra components) | No additional infra to manage |
| **Deployment complexity** | Moderate - collector + gateway config | Low - just configure the Scout endpoint in the app |
| **Best for** | Production apps with multiple devices, teams that want control over sampling and PII | Prototypes, internal tools, or apps with a small user base |
:::tip Recommendation
Use the **Collector + API Gateway** approach for production apps. The ability to
rate limit, sample server-side, strip PII, and rotate credentials without app
updates far outweighs the small infra cost. Reserve **Direct to Scout** for
prototypes or internal tools where simplicity matters more than control.
:::
### Step 1: Add the SDK
The `scout_flutter` SDK is a single package with one `initialize()` call —
it auto-captures the full RUM set (taps, navigation, errors, native crashes,
ANR, HTTP, frame metrics, logs) and exports OTLP, with no pipeline to
hand-build. It is published on
[pub.dev](https://pub.dev/packages/scout_flutter). Add it to your
`pubspec.yaml`:
```yaml title="pubspec.yaml"
dependencies:
scout_flutter: ^0.2.0
```
Then install:
```bash
flutter pub get
```
### Step 2: Initialize Telemetry
Initialize the SDK before `runApp()` in `lib/main.dart`:
```dart title="lib/main.dart"
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:scout_flutter/scout_flutter.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
// Fire-and-forget — never block app startup on SDK init.
unawaited(ScoutFlutter.initialize(
config: ScoutFlutterConfig(
serviceName: 'my-app',
endpoint: 'https://otel.example.com',
headers: const {'Authorization': 'Bearer '},
),
));
runApp(ScoutFlutter.observeScroll(child: const MyApp()));
}
```
Attach `ScoutFlutter.navigatorObserver` to every `Navigator` for screen
tracking. See the [SDK reference](../instrument/mobile/flutter.md) for the
full configuration surface.
Then run:
```bash
flutter run
```
### Step 3: Verify Telemetry
Once the app is running, confirm spans are reaching your collector.
1. **Check collector logs** - look for incoming OTLP requests:
```bash
docker logs otel-collector 2>&1 | grep -i "traces"
```
2. **Look for expected span names**:
- `screen_view`, `user_interaction`, `http.request`, `app_startup`,
`error`, `native_crash`, `anr`
3. **Open Scout** - navigate to the Traces view and filter by
`service.name = your-app-name`. You should see spans arriving within
30 seconds of app activity.
> **Troubleshooting**: If no spans appear, check that the
> `OTEL_TRACE_ENDPOINT` environment variable or hardcoded endpoint matches your
> collector's OTLP receiver address. See
> [Troubleshooting Missing Telemetry Data](./troubleshooting-missing-data.md)
> for common issues.
### Next Steps
- [Scout Flutter SDK reference](../instrument/mobile/flutter.md) - full
configuration and capability reference
- [Create Your First Dashboard](./create-your-first-dashboard.md) - build
dashboards from your mobile telemetry data
- [Creating Alerts with LogX](./creating-alerts-with-logx.md) - set up alerts
on crash rates, ANR counts, or slow screen loads
- [Troubleshooting Missing Telemetry Data](./troubleshooting-missing-data.md)
- diagnose issues when spans are not arriving
---
## 5-Minute Quick Start
Get Scout up and running in less than 5 minutes. This guide will help you deploy
an OpenTelemetry collector, configure it to send data to Scout, and start
collecting telemetry from your applications and infrastructure.
### Time to Complete
5-10 minutes
### What You'll Accomplish
- Deploy an OpenTelemetry collector in your environment
- Configure the Scout exporter to send data to the Scout backend
- Start collecting telemetry data from your applications and components
- Verify data is flowing to Scout
### Prerequisites
- Access to your Scout account and API credentials
- Docker, Kubernetes, or Linux environment for collector deployment
- Basic familiarity with YAML configuration files
### Step 1: Deploy the OpenTelemetry Collector
Deploy the OpenTelemetry collector in your environment based on your
infrastructure. Choose from Docker, Kubernetes, Linux, or AWS ECS options.
#### Docker Compose
Perfect for local development and testing environments. Download the
configuration and deploy using Docker Compose.
See the
[Docker Compose Setup Guide](../instrument/collector-setup/docker-compose-example.md)
for detailed instructions.
#### Kubernetes
Ideal for production deployments on EKS, GKE, or AKS. Install using Helm charts
for simplified deployment and management.
See the
[Kubernetes Helm Setup Guide](../instrument/collector-setup/kubernetes-helm-setup.md)
for detailed instructions.
#### Linux
For direct installation on Linux servers or virtual machines. Download and
install the collector binary with automated setup scripts.
See the [Linux Setup Guide](../instrument/collector-setup/linux-setup.md) for
detailed instructions.
#### AWS ECS
For containerized applications running on Amazon ECS. Deploy as a sidecar or
daemon on Fargate and EC2 launch types.
See the [ECS Setup Guide](../instrument/collector-setup/ecs-setup.md) for
detailed instructions on Fargate and EC2 deployments.
### Step 2: Configure the OpenTelemetry Collector
Once your collector is deployed, configure it to receive telemetry and send data
to Scout. Here's a complete configuration example:
```yaml showLineNumbers
# Extensions provide additional capabilities to the collector
extensions:
oauth2client:
client_id: __YOUR_CLIENT_ID__
client_secret: __YOUR_CLIENT_SECRET__
endpoint_params:
audience: b14collector
token_url: https://id.b14.dev/realms/__YOUR_TENANT__/protocol/openid-connect/token
tls:
insecure_skip_verify: true
# Receivers define how telemetry data enters the collector
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
# Processors modify or enrich telemetry data
processors:
resource:
attributes:
- key: deployment.environment
value: production
action: upsert
- key: environment
value: production
action: upsert
- key: service.namespace
value: my-namespace
action: upsert
# Exporters define where telemetry data is sent
exporters:
otlp/scout:
endpoint: https://api.scout.base14.io:4317
auth:
authenticator: oauth2client
tls:
insecure_skip_verify: true
# Service section ties everything together
service:
extensions: [oauth2client]
pipelines:
traces:
receivers: [otlp]
processors: [resource]
exporters: [otlp/scout]
metrics:
receivers: [otlp]
processors: [resource]
exporters: [otlp/scout]
logs:
receivers: [otlp]
processors: [resource]
exporters: [otlp/scout]
```
#### Configuration Breakdown
- **OAuth Extension**: Handles authentication with Scout using OAuth2 client
credentials flow
- **OTLP Receiver**: Accepts telemetry data on ports 4317 (gRPC) and 4318 (HTTP)
- **Resource Processor**: Adds environment metadata to all telemetry signals
- **Scout Exporter**: Sends data to Scout backend using authenticated OTLP
#### Quick Setup Steps
1. Get your OAuth credentials (client ID and secret) from the Scout dashboard
2. Update the configuration with your credentials
3. Save the configuration to `otel-collector-config.yaml`
4. Restart the collector to apply changes
See the
[Scout Exporter Configuration Guide](../instrument/collector-setup/scout-exporter.md)
for complete details and authentication options.
### Step 3: Start Collecting Data
Now that your collector is running, start sending telemetry data:
#### From Applications
Instrument your applications to send traces, metrics, and logs:
- **Auto-instrumentation**: Get started quickly with zero-code instrumentation
for popular frameworks. See
[Auto-instrumentation Guides](/instrument/apps/auto-instrumentation/)
- **Custom instrumentation**: Add application-specific telemetry. See
[Custom Instrumentation Guides](/instrument/apps/custom-instrumentation/)
#### From Components
Collect metrics and logs from your infrastructure components:
- **Databases**: [PostgreSQL](../instrument/component/postgres.md),
[MongoDB](../instrument/component/mongodb.md),
[Redis](../instrument/component/redis.md)
- **Message Queues**: [RabbitMQ](../instrument/component/rabbitmq.md)
- **Web Servers**: [nginx](../instrument/component/nginx.md)
#### From AWS Infrastructure
Monitor your AWS resources automatically:
- [Application Load Balancer](../instrument/infra/aws/elb.md)
- [RDS Databases](../instrument/infra/aws/rds.md)
- [ElastiCache](../instrument/infra/aws/elasticache.md)
- [VPC Flow Logs](../instrument/infra/aws/aws-vpc.md)
### Verification
1. Log in to your Scout dashboard
2. Navigate to the **Traces**, **Metrics**, or **Logs** section
3. Verify that data is appearing from your collector
4. Explore the service map to see your application topology
### What's Next?
Now that you have Scout running, explore these topics:
- **Send your first trace**: Run a
[Hello World app](/instrument/apps/hello-world) in any of 9 languages to
see traces, logs, and metrics flowing through your collector
- **Fine-tune your collector**: Review the
[OTel Collector Configuration Guide](../instrument/collector-setup/otel-collector-config.md)
to optimize your collector setup
- **Create dashboards**: Build custom visualizations for your metrics
- **Set up alerts**: Configure alerts to get notified about issues. See
[Creating Alerts with LogX](creating-alerts-with-logx.md)
- **Transform data**: Apply filters and transformations to your telemetry. See
[Filters and Transformations](/category/filters-and-transformations)
### Troubleshooting
If you're not seeing data in Scout:
1. **Check collector logs**: Verify the collector is running without errors
2. **Verify connectivity**: Ensure your collector can reach the Scout backend
3. **Check credentials**: Confirm your API token is valid and properly
configured
4. **Review configuration**: Validate your YAML configuration syntax
### Related Guides
- [Introduction](../introduction.md) - Complete overview of Scout's capabilities
- [Docker Compose Setup](../instrument/collector-setup/docker-compose-example.md)
\- Detailed Docker setup
- [OTel Collector Configuration](../instrument/collector-setup/otel-collector-config.md)
\- Advanced collector configuration
- [Scout Exporter](../instrument/collector-setup/scout-exporter.md) - Exporter
configuration details
- [Dashboards and Alerts](../operate/dashboards-and-alerts.md) - Create
visualizations and alerts
---
## Troubleshooting Missing Telemetry Data
This guide provides a systematic approach to debug telemetry pipeline issues
by starting with Grafana dashboards, then moving to the OpenTelemetry
Collector, and finally to your application.
### Step 1: Check OTel Collector Data Flow Dashboard
Start by checking the **OTel Collector Data Flow** dashboard in Grafana.
#### If No Data in Dashboard
There's an issue in the pipeline. Check the collector logs.
##### Viewing Collector Logs
**Docker:**
```bash
docker logs otel-collector --tail=100
```
**Kubernetes:**
```bash
kubectl logs -n --tail=100
```
**Linux Service:**
```bash
sudo journalctl -u otel-collector -n 100
```
##### What to Look For in Logs
**Successful startup:**
```text
Everything is ready. Begin running and processing data.
```
**Configuration errors:**
```text
error decoding config
invalid configuration
```
**Authentication errors:**
```text
rpc error: code = Unauthenticated
401 Unauthorized
```
**Connection errors:**
```text
connection refused
failed to connect
```
**If logs show errors:** Validate your collector configuration using:
```bash
otelcol validate --config=/path/to/otel-collector-config.yaml
```
Or use [otelbin.io](https://www.otelbin.io/).
**If no errors in logs and application is not throwing errors:** Verify the
receiver is configured in the pipeline:
```yaml
service:
pipelines:
metrics:
receivers: [otlp] # Ensure your receiver is listed
exporters: [otlp]
```
#### If Data Appears in Dashboard
Proceed to Step 2.
### Step 2: Check Metrics Collected Dashboard
Go to the **Metrics Collected** dashboard and select your environment.
#### If You See Your Service and Data
Your telemetry is flowing correctly. Use the correct filters and dashboard
options to view your data.
#### If No Data for Your Service
Check your application logs to verify it's successfully exporting telemetry.
**Look for:**
- OpenTelemetry SDK initialization messages
- Successful telemetry export confirmations
- Any SDK errors or warnings
**If application shows no errors:** Verify the receiver is used in the
collector pipeline (see Step 1).
### Related Guides
- [Collector Configuration](../instrument/collector-setup/otel-collector-config.md)
Detailed collector configuration guide
- [Auto Instrumentation](../instrument/apps/auto-instrumentation/express.md)
Automatic application instrumentation
- [Custom Instrumentation](../instrument/apps/custom-instrumentation/python.md)
Manual application instrumentation
---
## Actix Web OpenTelemetry Instrumentation - Complete APM Setup Guide
## Actix Web
Implement OpenTelemetry instrumentation for Rust Actix Web applications to
enable comprehensive application performance monitoring (APM), distributed
tracing, and observability. This guide shows you how to instrument your Actix
Web application to collect traces, metrics, and logs from HTTP requests,
database queries, background jobs, and custom business logic using the
OpenTelemetry Rust SDK.
Actix Web is a mature, high-performance Rust web framework, alongside the
Tokio-based [Axum](./axum.md).
Rust applications built with Actix Web benefit from the powerful `tracing`
ecosystem combined with OpenTelemetry exporters. With the
`tracing-actix-web` crate, you can automatically capture spans from every
HTTP request, monitor SQLx database queries, trace distributed transactions
across microservices, and identify performance bottlenecks with minimal
runtime overhead. Actix Web's actor-based architecture and
`tracing-actix-web` middleware provide seamless integration with
OpenTelemetry's context propagation.
Whether you're implementing observability for the first time, migrating from
other monitoring solutions, or troubleshooting performance issues in
production, this guide provides production-ready configurations and best
practices for Rust Actix Web OpenTelemetry instrumentation.
:::tip TL;DR
Add `tracing-actix-web` middleware with `tracing-opentelemetry` to export spans
via OTLP. SQLx queries and HTTP requests are traced automatically. Use
`BatchSpanProcessor` for production and export to base14 Scout via the
OpenTelemetry Collector.
:::
> **Note:** This guide provides a practical Actix Web-focused overview based
> on the official OpenTelemetry documentation. For complete Rust language
> information, please consult the
> [official OpenTelemetry Rust documentation](https://opentelemetry.io/docs/languages/rust/).
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Who This Guide Is For
This documentation is designed for:
- **Rust developers:** implementing observability and distributed tracing for
Actix Web applications
- **DevOps engineers:** deploying Rust applications with production monitoring
requirements
- **Engineering teams:** migrating from other APM solutions to OpenTelemetry
- **Developers:** debugging performance issues, slow database queries, or async
runtime problems
- **Platform teams:** standardizing observability across multiple Rust services
### Overview
This comprehensive guide demonstrates how to:
- Install and configure OpenTelemetry SDK for Actix Web applications
- Set up tracing with `tracing-actix-web` for automatic HTTP span collection
- Configure OTLP export for traces, metrics, and logs to Scout Collector
- Implement custom instrumentation for business-critical operations
- Monitor SQLx database queries and connection pools
- Instrument a PostgreSQL-backed job queue with W3C trace propagation
- Deploy instrumented Actix Web applications to production
- Troubleshoot common instrumentation issues and optimize performance
- Include trace IDs in API error responses
### Prerequisites
Before starting, ensure you have:
- **Rust 1.92 or later** (stable toolchain recommended)
- Edition 2024 required
- **Actix Web 4.12 or later** web framework
- **Cargo** for dependency management
- **Scout Collector** configured and accessible
- See
[Docker Compose Setup](../../collector-setup/docker-compose-example.md) for
local development
- Production deployments should use a dedicated Scout Collector instance
- Basic understanding of OpenTelemetry concepts (traces, spans, attributes)
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version |
| --------------------- | --------------- | ------------------- |
| Rust | 1.80.0 | 1.92.0+ |
| Actix Web | 4.0.0 | 4.12+ |
| tracing-actix-web | 0.7.0 | 0.7+ |
| OpenTelemetry | 0.27.0 | 0.32+ |
| tracing-opentelemetry | 0.28.0 | 0.33+ |
| SQLx | 0.7.0 | 0.8.6+ |
### Required Packages
Add the following dependencies to your `Cargo.toml`:
```toml showLineNumbers title="Cargo.toml"
[package]
name = "actix-postgres"
version = "1.0.0"
edition = "2024"
rust-version = "1.92"
[[bin]]
name = "api"
path = "src/main.rs"
[[bin]]
name = "worker"
path = "src/bin/worker.rs"
[dependencies]
# Web Framework
actix-web = "4.12"
actix-rt = "2"
tracing-actix-web = "0.7"
# Async Runtime
tokio = { version = "1.49.0", features = ["full", "tracing"] }
# Database
sqlx = { version = "0.8.6", features = [
"runtime-tokio", "tls-rustls", "postgres",
"macros", "migrate", "uuid", "time", "json",
] }
# OpenTelemetry
opentelemetry = "0.32"
opentelemetry_sdk = { version = "0.32", features = ["rt-tokio", "logs"] }
opentelemetry-otlp = { version = "0.32", features = ["grpc-tonic", "trace", "logs"] }
opentelemetry-appender-tracing = "0.32"
# Tracing
tracing = "0.1.44"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-opentelemetry = "0.33"
# Authentication
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
argon2 = "0.5.3"
# Serialization
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0"
# Utilities
uuid = { version = "1.19.0", features = ["v4", "serde"] }
time = { version = "0.3.47", features = ["serde", "formatting", "macros"] }
thiserror = "2.0.17"
anyhow = "1.0.100"
dotenvy = "0.15"
```
### Configuration
OpenTelemetry Rust instrumentation can be configured using multiple approaches
depending on your deployment requirements and preferences. Choose the method
that best fits your application architecture.
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
The recommended approach is to create a dedicated telemetry module. This
provides the most flexibility and keeps configuration separate from your
application bootstrap.
```rust showLineNumbers title="src/telemetry/init.rs"
use opentelemetry::KeyValue;
use opentelemetry::global;
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::{Resource, logs::SdkLoggerProvider, trace::SdkTracerProvider};
use std::time::Duration;
use tracing_opentelemetry::OpenTelemetryLayer;
use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt};
use crate::config::Config;
pub struct TelemetryGuard {
pub tracer_provider: SdkTracerProvider,
pub logger_provider: SdkLoggerProvider,
}
impl TelemetryGuard {
pub fn shutdown(&self) {
if let Err(e) = self.tracer_provider.shutdown() {
eprintln!("Error shutting down tracer provider: {e}");
}
if let Err(e) = self.logger_provider.shutdown() {
eprintln!("Error shutting down logger provider: {e}");
}
}
}
pub fn init_telemetry(config: &Config) -> anyhow::Result {
let resource = Resource::builder()
.with_service_name(config.otel_service_name.clone())
.with_attribute(KeyValue::new("service.version", "1.0.0"))
.with_attribute(KeyValue::new("service.namespace", "examples"))
.with_attribute(KeyValue::new(
"deployment.environment",
config.environment.clone(),
))
.with_attribute(KeyValue::new(
"environment",
config.environment.clone(),
))
.build();
let trace_exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(&config.otel_exporter_endpoint)
.with_timeout(Duration::from_secs(10))
.build()?;
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(trace_exporter)
.with_resource(resource.clone())
.build();
global::set_tracer_provider(tracer_provider.clone());
let log_exporter = opentelemetry_otlp::LogExporter::builder()
.with_tonic()
.with_endpoint(&config.otel_exporter_endpoint)
.with_timeout(Duration::from_secs(10))
.build()?;
let logger_provider = SdkLoggerProvider::builder()
.with_batch_exporter(log_exporter)
.with_resource(resource)
.build();
let otel_log_layer = OpenTelemetryTracingBridge::new(&logger_provider);
let tracer = global::tracer(config.otel_service_name.clone());
let telemetry_layer = OpenTelemetryLayer::new(tracer);
let env_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn"));
let fmt_layer = if config.is_production() {
tracing_subscriber::fmt::layer().json().boxed()
} else {
tracing_subscriber::fmt::layer().pretty().boxed()
};
tracing_subscriber::registry()
.with(env_filter)
.with(telemetry_layer)
.with(otel_log_layer)
.with(fmt_layer)
.init();
tracing::info!(
service = %config.otel_service_name,
endpoint = %config.otel_exporter_endpoint,
"Telemetry initialized with OTLP trace and log export"
);
Ok(TelemetryGuard {
tracer_provider,
logger_provider,
})
}
```
```mdx-code-block
```
For containerized deployments or environments where configuration is managed
externally, you can rely on environment variables:
```rust showLineNumbers title="src/telemetry/init.rs"
use std::env;
pub fn init_telemetry_from_env() -> anyhow::Result {
let config = Config::from_env();
init_telemetry(&config)
}
```
With this configuration, use environment variables to control behavior:
```bash showLineNumbers
export OTEL_SERVICE_NAME=actix-postgres
export OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4317
export RUST_LOG=info,sqlx=warn
```
```mdx-code-block
```
For applications using a configuration struct pattern:
```rust showLineNumbers title="src/config.rs"
use std::env;
#[derive(Debug, Clone)]
pub struct Config {
pub port: u16,
pub environment: String,
pub database_url: String,
pub jwt_secret: String,
pub jwt_expires_in_hours: i64,
pub otel_service_name: String,
pub otel_exporter_endpoint: String,
}
impl Config {
pub fn from_env() -> Self {
dotenvy::dotenv().ok();
Self {
port: env::var("PORT")
.unwrap_or_else(|_| "8080".to_string())
.parse()
.expect("PORT must be a number"),
environment: env::var("ENVIRONMENT")
.unwrap_or_else(|_| "development".to_string()),
database_url: env::var("DATABASE_URL")
.expect("DATABASE_URL must be set"),
jwt_secret: env::var("JWT_SECRET")
.expect("JWT_SECRET must be set"),
jwt_expires_in_hours: env::var("JWT_EXPIRES_IN_HOURS")
.unwrap_or_else(|_| "168".to_string())
.parse()
.expect("JWT_EXPIRES_IN_HOURS must be a number"),
otel_service_name: env::var("OTEL_SERVICE_NAME")
.unwrap_or_else(|_| "actix-postgres".to_string()),
otel_exporter_endpoint: env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:4317".to_string()),
}
}
pub fn is_production(&self) -> bool {
self.environment == "production"
}
}
```
```mdx-code-block
```
#### Configuring TracingLogger Middleware
Actix Web uses the `tracing-actix-web` crate for automatic HTTP request
instrumentation. Unlike Axum's Tower-based `TraceLayer`, Actix Web uses
`TracingLogger` as native middleware:
```rust showLineNumbers title="src/main.rs"
use actix_web::{App, HttpServer, web};
use tracing_actix_web::TracingLogger;
use config::Config;
use database::create_pool;
use jobs::JobQueue;
use repository::{ArticleRepository, FavoriteRepository, UserRepository};
use services::{ArticleService, AuthService};
use telemetry::init_telemetry;
#[actix_web::main]
async fn main() -> anyhow::Result<()> {
let config = Config::from_env();
let telemetry_guard = init_telemetry(&config)?;
tracing::info!(
port = config.port,
environment = %config.environment,
"Starting server"
);
let pool = create_pool(&config).await?;
let user_repo = UserRepository::new(pool.clone());
let article_repo = ArticleRepository::new(pool.clone());
let favorite_repo = FavoriteRepository::new(pool.clone());
let job_queue = JobQueue::new(pool.clone());
let auth_service = AuthService::new(user_repo, &config);
let article_service = ArticleService::new(article_repo, favorite_repo, job_queue);
let pool_data = web::Data::new(pool);
let auth_data = web::Data::new(auth_service);
let article_data = web::Data::new(article_service);
let bind_addr = format!("0.0.0.0:{}", config.port);
tracing::info!(addr = %bind_addr, "Server listening");
HttpServer::new(move || {
App::new()
.wrap(TracingLogger::default())
.wrap(actix_web::middleware::Compress::default())
.app_data(pool_data.clone())
.app_data(auth_data.clone())
.app_data(article_data.clone())
.configure(routes::configure)
})
.bind(&bind_addr)?
.run()
.await?;
tracing::info!("Server shutdown complete");
telemetry_guard.shutdown();
Ok(())
}
```
`TracingLogger::default()` creates a span for every HTTP request with
method, path, status code, and duration - no custom `MakeSpan` or
`OnResponse` implementations needed.
#### Shared State with `web::Data`
Actix Web uses `web::Data` (backed by `Arc`) for shared application state,
unlike Axum's `State` extractor:
```rust showLineNumbers title="src/main.rs"
let pool_data = web::Data::new(pool);
let auth_data = web::Data::new(auth_service);
let article_data = web::Data::new(article_service);
HttpServer::new(move || {
App::new()
.app_data(pool_data.clone())
.app_data(auth_data.clone())
.app_data(article_data.clone())
.configure(routes::configure)
})
```
#### Scout Collector Integration
When using Scout Collector, configure your Actix Web application to send
telemetry data to the Scout Collector endpoint:
```rust showLineNumbers title="src/telemetry/init.rs"
let trace_exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(&config.otel_exporter_endpoint)
.with_timeout(Duration::from_secs(10))
.build()?;
```
> **Scout Dashboard Integration**: After configuration, your traces will appear
> in the Scout Dashboard. Navigate to the Traces section to view request flows,
> identify performance bottlenecks, and analyze distributed transactions across
> your Rust services.
### Production Configuration
#### Docker Production Configuration
For containerized Actix Web applications:
```dockerfile showLineNumbers title="Dockerfile"
# Build stage
FROM rust:1.92-alpine AS builder
WORKDIR /app
RUN apk add --no-cache musl-dev openssl-dev pkgconfig
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && \
echo "fn main() {}" > src/main.rs && \
mkdir -p src/bin && \
echo "fn main() {}" > src/bin/worker.rs && \
echo "" > src/lib.rs
RUN cargo build --release 2>/dev/null || true
RUN rm -rf src
COPY src ./src
COPY migrations ./migrations
RUN touch src/main.rs src/lib.rs && \
cargo build --release --bin api
# Runtime stage
FROM alpine:3.21
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata wget && \
adduser -D -g '' -u 1001 appuser
COPY --from=builder /app/target/release/api .
COPY --from=builder /app/migrations ./migrations
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost:8080/api/health || exit 1
CMD ["./api"]
```
Actix Web applications with background workers require a separate Dockerfile
for the worker binary:
```dockerfile showLineNumbers title="Dockerfile.worker"
FROM rust:1.92-alpine AS builder
WORKDIR /app
RUN apk add --no-cache musl-dev openssl-dev pkgconfig
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && \
mkdir -p src/bin && echo "fn main() {}" > src/bin/worker.rs && \
echo "" > src/lib.rs
RUN cargo build --release 2>/dev/null || true
RUN rm -rf src
COPY src ./src
COPY migrations ./migrations
RUN touch src/main.rs src/lib.rs src/bin/worker.rs && \
cargo build --release --bin worker
FROM alpine:3.21
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata && \
adduser -D -g '' -u 1001 appuser
COPY --from=builder /app/target/release/worker .
COPY --from=builder /app/migrations ./migrations
USER appuser
CMD ["./worker"]
```
#### Docker Compose Configuration
```yaml showLineNumbers title="compose.yml"
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
PORT: "8080"
ENVIRONMENT: development
DATABASE_URL: postgres://postgres:postgres@postgres:5432/actix_postgres_app?sslmode=disable
JWT_SECRET: your-super-secret-jwt-key-change-in-production
OTEL_SERVICE_NAME: actix-postgres-api
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
RUST_LOG: info,sqlx=warn
depends_on:
postgres:
condition: service_healthy
otel-collector:
condition: service_started
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/api/health"]
interval: 10s
timeout: 5s
retries: 5
worker:
build:
context: .
dockerfile: Dockerfile.worker
environment:
ENVIRONMENT: development
DATABASE_URL: postgres://postgres:postgres@postgres:5432/actix_postgres_app?sslmode=disable
JWT_SECRET: your-super-secret-jwt-key-change-in-production
OTEL_SERVICE_NAME: actix-postgres-worker
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
RUST_LOG: info,sqlx=warn
depends_on:
postgres:
condition: service_healthy
otel-collector:
condition: service_started
postgres:
image: postgres:18.2-alpine3.23
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: actix_postgres_app
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
otel-collector:
image: otel/opentelemetry-collector-contrib:0.144.0
command: ["--config=/etc/otel-config.yaml"]
volumes:
- ./config/otel-config.yaml:/etc/otel-config.yaml:ro
ports:
- "4317:4317"
- "4318:4318"
- "13133:13133"
env_file:
- path: .env
required: false
environment:
- SCOUT_ENDPOINT=${SCOUT_ENDPOINT:-http://localhost:4318}
- SCOUT_CLIENT_ID=${SCOUT_CLIENT_ID:-}
- SCOUT_CLIENT_SECRET=${SCOUT_CLIENT_SECRET:-}
- SCOUT_TOKEN_URL=${SCOUT_TOKEN_URL:-}
- SCOUT_ENVIRONMENT=${SCOUT_ENVIRONMENT:-development}
volumes:
postgres_data:
```
#### OpenTelemetry Collector Configuration
```yaml showLineNumbers title="config/otel-config.yaml"
extensions:
oauth2client:
client_id: ${env:SCOUT_CLIENT_ID}
client_secret: ${env:SCOUT_CLIENT_SECRET}
token_url: ${env:SCOUT_TOKEN_URL}
endpoint_params:
audience: b14collector
timeout: 10s
health_check:
endpoint: 0.0.0.0:13133
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
limit_mib: 256
check_interval: 1s
batch:
timeout: 10s
send_batch_size: 1024
filter/noisy:
traces:
span:
- 'IsMatch(name, ".*/api/health")'
exporters:
otlp_http/b14:
endpoint: ${env:SCOUT_ENDPOINT}
auth:
authenticator: oauth2client
compression: gzip
timeout: 30s
retry_on_failure:
enabled: true
initial_interval: 1s
max_interval: 30s
max_elapsed_time: 300s
debug:
verbosity: basic
service:
extensions: [oauth2client, health_check]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, filter/noisy, batch]
exporters: [otlp_http/b14, debug]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp_http/b14, debug]
logs:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp_http/b14, debug]
```
#### Production Environment Variables
```bash showLineNumbers title=".env.production"
OTEL_SERVICE_NAME=actix-postgres-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4317
RUST_LOG=info,sqlx=warn
ENVIRONMENT=production
DATABASE_URL=postgres://user:pass@db:5432/production
```
### Metrics
OpenTelemetry can collect custom metrics from your Actix Web application for
resource utilization, request rates, error counts, and business metrics.
#### Defining Custom Metrics
Create a metrics module with static metric definitions using `LazyLock`:
```rust showLineNumbers title="src/telemetry/metrics.rs"
use opentelemetry::{
global,
metrics::{Counter, Histogram, Meter},
};
use std::sync::LazyLock;
pub static METER: LazyLock = LazyLock::new(|| global::meter("actix-postgres"));
pub static HTTP_REQUESTS_TOTAL: LazyLock> = LazyLock::new(|| {
METER
.u64_counter("http.requests.total")
.with_description("Total number of HTTP requests")
.with_unit("{request}")
.build()
});
pub static HTTP_REQUEST_DURATION: LazyLock> = LazyLock::new(|| {
METER
.f64_histogram("http.request.duration")
.with_description("HTTP request duration in milliseconds")
.with_unit("ms")
.with_boundaries(vec![
1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0,
1000.0, 2500.0, 5000.0, 10000.0,
])
.build()
});
pub static ARTICLES_CREATED: LazyLock> = LazyLock::new(|| {
METER.u64_counter("articles.created")
.with_description("Total articles created").build()
});
pub static ARTICLES_UPDATED: LazyLock> = LazyLock::new(|| {
METER.u64_counter("articles.updated")
.with_description("Total articles updated").build()
});
pub static ARTICLES_DELETED: LazyLock> = LazyLock::new(|| {
METER.u64_counter("articles.deleted")
.with_description("Total articles deleted").build()
});
pub static FAVORITES_ADDED: LazyLock> = LazyLock::new(|| {
METER.u64_counter("favorites.added")
.with_description("Total favorites added").build()
});
pub static FAVORITES_REMOVED: LazyLock> = LazyLock::new(|| {
METER.u64_counter("favorites.removed")
.with_description("Total favorites removed").build()
});
pub static USERS_REGISTERED: LazyLock> = LazyLock::new(|| {
METER.u64_counter("users.registered")
.with_description("Total users registered").build()
});
pub static JOBS_ENQUEUED: LazyLock> = LazyLock::new(|| {
METER.u64_counter("jobs.enqueued")
.with_description("Total jobs enqueued").build()
});
pub static JOBS_COMPLETED: LazyLock> = LazyLock::new(|| {
METER.u64_counter("jobs.completed")
.with_description("Total jobs completed successfully").build()
});
pub static JOBS_FAILED: LazyLock> = LazyLock::new(|| {
METER.u64_counter("jobs.failed")
.with_description("Total jobs failed").build()
});
```
#### Recording Business Metrics
Track business-specific events in service methods:
```rust showLineNumbers title="src/services/article.rs"
use crate::telemetry::{ARTICLES_CREATED, ARTICLES_DELETED, FAVORITES_ADDED};
ARTICLES_CREATED.add(1, &[]);
tracing::info!(article_id = article.id, slug = %article.slug, "Article created");
```
### SQLx Database Monitoring
OpenTelemetry integrates with SQLx through the tracing ecosystem to provide
comprehensive database query monitoring.
#### Configuring SQLx Connection Pool
```rust showLineNumbers title="src/database/pool.rs"
use sqlx::postgres::{PgPool, PgPoolOptions};
pub async fn create_pool(config: &Config) -> Result {
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(5)
.acquire_timeout(Duration::from_secs(30))
.idle_timeout(Duration::from_secs(600))
.max_lifetime(Duration::from_secs(1800))
.connect(&config.database_url)
.await?;
tracing::info!("Database connection pool created");
Ok(pool)
}
```
#### Instrumenting Repository Methods
Use the `#[instrument]` macro for automatic span creation on repository
methods:
```rust showLineNumbers title="src/repository/article.rs"
use tracing::instrument;
#[derive(Clone)]
pub struct ArticleRepository {
pool: PgPool,
}
impl ArticleRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
#[instrument(name = "db.article.create", skip(self))]
pub async fn create(
&self,
slug: &str,
title: &str,
description: &str,
body: &str,
author_id: i32,
) -> Result {
sqlx::query_as!(
Article,
r#"
INSERT INTO articles (slug, title, description, body, author_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, slug, title, description, body, author_id,
favorites_count, created_at, updated_at
"#,
slug, title, description, body, author_id
)
.fetch_one(&self.pool)
.await
}
#[instrument(name = "db.article.find_by_slug", skip(self))]
pub async fn find_by_slug(&self, slug: &str) -> Result, sqlx::Error> {
sqlx::query_as!(Article, "SELECT * FROM articles WHERE slug = $1", slug)
.fetch_optional(&self.pool)
.await
}
}
```
### Custom Manual Instrumentation
#### Creating Custom Spans with the Instrument Macro
Use the `#[instrument]` macro from the `tracing` crate on service methods:
```rust showLineNumbers title="src/services/article.rs"
use tracing::instrument;
#[derive(Clone)]
pub struct ArticleService {
article_repo: ArticleRepository,
favorite_repo: FavoriteRepository,
job_queue: JobQueue,
}
impl ArticleService {
#[instrument(name = "article.create", skip(self, input), fields(author_id))]
pub async fn create(
&self,
author_id: i32,
input: CreateArticleInput,
) -> AppResult {
let slug = self.generate_slug(&input.title);
let final_slug = if self.article_repo.exists_by_slug(&slug).await? {
format!("{}-{}", slug, time::OffsetDateTime::now_utc().unix_timestamp())
} else {
slug
};
let article = self.article_repo
.create(&final_slug, &input.title,
input.description.as_deref().unwrap_or(""),
&input.body, author_id)
.await?;
if let Err(e) = self.job_queue
.enqueue_notification(article.id, &article.title).await
{
tracing::warn!(article_id = article.id, error = %e,
"Failed to enqueue notification");
}
ARTICLES_CREATED.add(1, &[]);
tracing::info!(article_id = article.id, slug = %article.slug,
"Article created");
Ok(ArticleResponse { article: ArticleDto::from(article) })
}
#[instrument(name = "article.delete", skip(self))]
pub async fn delete(&self, slug: &str, user_id: i32) -> AppResult<()> {
let article = self.article_repo.find_by_slug(slug).await?
.ok_or(AppError::NotFound("Article not found".to_string()))?;
if article.author_id != user_id {
return Err(AppError::Forbidden);
}
self.article_repo.delete(article.id).await?;
ARTICLES_DELETED.add(1, &[]);
tracing::info!(article_id = article.id, "Article deleted");
Ok(())
}
#[instrument(name = "article.favorite", skip(self))]
pub async fn favorite(&self, slug: &str, user_id: i32) -> AppResult {
let article = self.article_repo.find_by_slug(slug).await?
.ok_or(AppError::NotFound("Article not found".to_string()))?;
let already_favorited = self.favorite_repo.exists(user_id, article.id).await?;
if !already_favorited {
self.favorite_repo.create(user_id, article.id).await?;
self.article_repo.increment_favorites(article.id).await?;
FAVORITES_ADDED.add(1, &[]);
tracing::info!(article_id = article.id, user_id, "Article favorited");
}
let updated = self.article_repo.find_by_id(article.id).await?
.ok_or(AppError::Internal("Failed to fetch article".to_string()))?;
Ok(ArticleResponse { article: ArticleDto::from(updated) })
}
}
```
#### Error Handling with Trace IDs
Actix Web uses the `ResponseError` trait for error handling. Include trace IDs
in error responses so users can reference them in support requests:
```rust showLineNumbers title="src/error.rs"
use actix_web::{HttpResponse, http::StatusCode};
use opentelemetry::trace::TraceContextExt;
use serde_json::json;
use thiserror::Error;
use tracing::Span;
use tracing_opentelemetry::OpenTelemetrySpanExt;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Authentication required")]
Unauthorized,
#[error("Invalid credentials")]
InvalidCredentials,
#[error("Forbidden")]
Forbidden,
#[error("Not found: {0}")]
NotFound(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("JWT error: {0}")]
Jwt(#[from] jsonwebtoken::errors::Error),
#[error("Internal error: {0}")]
Internal(String),
}
fn get_trace_id() -> Option {
let span = Span::current();
let context = span.context();
let span_ref = context.span();
let span_context = span_ref.span_context();
if span_context.is_valid() {
Some(span_context.trace_id().to_string())
} else {
None
}
}
impl actix_web::ResponseError for AppError {
fn status_code(&self) -> StatusCode {
match self {
AppError::Unauthorized | AppError::InvalidCredentials
| AppError::Jwt(_) => StatusCode::UNAUTHORIZED,
AppError::Forbidden => StatusCode::FORBIDDEN,
AppError::NotFound(_) => StatusCode::NOT_FOUND,
AppError::Conflict(_) => StatusCode::CONFLICT,
AppError::Validation(_) => StatusCode::BAD_REQUEST,
AppError::Database(_) | AppError::Internal(_) =>
StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse {
let status = self.status_code();
let error_message = match self {
AppError::Database(e) => {
tracing::error!(error = %e, "Database error");
"Internal server error".to_string()
}
AppError::Internal(msg) => {
tracing::error!(error = %msg, "Internal error");
"Internal server error".to_string()
}
_ => self.to_string(),
};
let body = if let Some(trace_id) = get_trace_id() {
json!({
"error": error_message,
"status": status.as_u16(),
"trace_id": trace_id,
})
} else {
json!({
"error": error_message,
"status": status.as_u16(),
})
};
HttpResponse::build(status).json(body)
}
}
pub type AppResult = Result;
```
#### Authentication Middleware with FromRequest
Actix Web uses the `FromRequest` trait for extractors. This pattern differs
from Axum's middleware layers:
```rust showLineNumbers title="src/middleware/auth.rs"
use actix_web::{FromRequest, HttpRequest, dev::Payload, web};
use std::future::{Ready, ready};
use crate::{error::AppError, services::AuthService};
pub struct AuthUser(pub i32);
impl FromRequest for AuthUser {
type Error = AppError;
type Future = Ready>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
let result = extract_and_validate(req, false);
ready(result.map(|id| AuthUser(id.expect("token required"))))
}
}
pub struct OptionalAuthUser(pub Option);
impl FromRequest for OptionalAuthUser {
type Error = AppError;
type Future = Ready>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
let result = extract_and_validate(req, true);
ready(result.map(OptionalAuthUser))
}
}
fn extract_and_validate(req: &HttpRequest, optional: bool) -> Result, AppError> {
let auth_service = req
.app_data::>()
.ok_or(AppError::Internal("AuthService not configured".to_string()))?;
let token = req.headers()
.get("Authorization")
.and_then(|value| value.to_str().ok())
.and_then(|header| header.strip_prefix("Bearer "));
match token {
Some(token) => match auth_service.validate_token(token) {
Ok(user_id) => Ok(Some(user_id)),
Err(_) if optional => Ok(None),
Err(e) => Err(e),
},
None if optional => Ok(None),
None => Err(AppError::Unauthorized),
}
}
```
#### Route Definitions
```rust showLineNumbers title="src/routes.rs"
use actix_web::web;
use crate::handlers;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.route("/api/health", web::get().to(handlers::health_check))
.route("/api/register", web::post().to(handlers::register))
.route("/api/login", web::post().to(handlers::login))
.route("/api/user", web::get().to(handlers::get_user))
.route("/api/articles", web::get().to(handlers::list_articles))
.route("/api/articles", web::post().to(handlers::create_article))
.route("/api/articles/{slug}", web::get().to(handlers::get_article))
.route("/api/articles/{slug}", web::put().to(handlers::update_article))
.route("/api/articles/{slug}", web::delete().to(handlers::delete_article))
.route("/api/articles/{slug}/favorite",
web::post().to(handlers::favorite_article))
.route("/api/articles/{slug}/favorite",
web::delete().to(handlers::unfavorite_article));
}
```
### Job Queue with Trace Propagation
This application uses a PostgreSQL-native job queue with `FOR UPDATE SKIP
LOCKED` for concurrent-safe job processing, and W3C Trace Context propagation
to link producer and consumer spans.
#### Enqueuing Jobs (Producer)
The job queue captures the current trace context and stores it as JSON in the
`trace_context` column:
```rust showLineNumbers title="src/jobs/queue.rs"
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row};
use std::collections::HashMap;
use tracing::{Span, instrument};
use crate::telemetry::{JOBS_COMPLETED, JOBS_ENQUEUED, JOBS_FAILED};
#[derive(Clone)]
pub struct JobQueue {
pool: PgPool,
}
impl JobQueue {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
#[instrument(name = "job.enqueue", skip(self, payload))]
pub async fn enqueue(
&self, kind: &str, payload: T,
) -> Result {
let trace_context = self.capture_trace_context();
let payload_json = serde_json::to_value(&payload)
.unwrap_or(serde_json::Value::Null);
let row = sqlx::query(
r#"
INSERT INTO jobs (kind, payload, trace_context)
VALUES ($1, $2, $3)
RETURNING id
"#,
)
.bind(kind)
.bind(&payload_json)
.bind(&trace_context)
.fetch_one(&self.pool)
.await?;
let job_id: i64 = row.get("id");
JOBS_ENQUEUED.add(1, &[]);
tracing::info!(job_id, kind, "Job enqueued");
Ok(job_id)
}
pub async fn dequeue(&self) -> Result, sqlx::Error> {
let result = sqlx::query(
r#"
UPDATE jobs
SET status = 'processing',
started_at = NOW(),
attempts = attempts + 1
WHERE id = (
SELECT id FROM jobs
WHERE status = 'pending'
AND scheduled_at <= NOW()
AND attempts < max_attempts
ORDER BY priority DESC, scheduled_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, kind, payload, status, attempts, trace_context
"#,
)
.fetch_optional(&self.pool)
.await?;
Ok(result.map(|row| Job {
id: row.get("id"),
kind: row.get("kind"),
payload: row.get("payload"),
status: row.get("status"),
attempts: row.get("attempts"),
trace_context: row.get("trace_context"),
}))
}
fn capture_trace_context(&self) -> Option {
use opentelemetry::trace::TraceContextExt;
use tracing_opentelemetry::OpenTelemetrySpanExt;
let span = Span::current();
let context = span.context();
let otel_span = context.span();
let span_context = otel_span.span_context();
if span_context.is_valid() {
let mut carrier = HashMap::new();
carrier.insert(
"traceparent".to_string(),
format!(
"00-{}-{}-{:02x}",
span_context.trace_id(),
span_context.span_id(),
span_context.trace_flags().to_u8()
),
);
Some(serde_json::to_value(&carrier).unwrap_or(serde_json::Value::Null))
} else {
None
}
}
}
```
#### Processing Jobs (Consumer)
The worker binary extracts the trace context from job data and restores the
parent span, linking the consumer trace to the original HTTP request:
```rust showLineNumbers title="src/bin/worker.rs"
use std::collections::HashMap;
use opentelemetry::propagation::TextMapPropagator;
use opentelemetry_sdk::propagation::TraceContextPropagator;
use tracing::Instrument;
use tracing_opentelemetry::OpenTelemetrySpanExt;
async fn process_job(job_queue: &JobQueue) -> anyhow::Result<()> {
let Some(job) = job_queue.dequeue().await? else {
return Ok(());
};
let parent_context = extract_trace_context(&job.trace_context);
let span = tracing::info_span!(
"job.process",
job_id = job.id,
job_kind = %job.kind,
);
let _ = span.set_parent(parent_context);
async {
tracing::info!(job_id = job.id, kind = %job.kind, "Processing job");
let result = match job.kind.as_str() {
"notification" => NotificationHandler::handle(&job).await,
_ => {
tracing::warn!(job_id = job.id, kind = %job.kind, "Unknown job kind");
Err(anyhow::anyhow!("Unknown job kind: {}", job.kind))
}
};
match result {
Ok(()) => {
job_queue.complete(job.id).await?;
tracing::info!(job_id = job.id, "Job completed");
}
Err(e) => {
job_queue.fail(job.id, &e.to_string()).await?;
tracing::error!(job_id = job.id, error = %e, "Job failed");
}
}
Ok(())
}
.instrument(span)
.await
}
fn extract_trace_context(
trace_context: &Option,
) -> opentelemetry::Context {
let Some(ctx_value) = trace_context else {
return opentelemetry::Context::new();
};
let carrier: HashMap = match serde_json::from_value(ctx_value.clone()) {
Ok(c) => c,
Err(_) => return opentelemetry::Context::new(),
};
let propagator = TraceContextPropagator::new();
propagator.extract(&carrier)
}
```
### Running Your Instrumented Application
```mdx-code-block
```
```bash showLineNumbers
RUST_LOG=debug cargo run --bin api
# In a separate terminal, start the worker
RUST_LOG=debug cargo run --bin worker
```
```mdx-code-block
```
```bash showLineNumbers
export OTEL_SERVICE_NAME=actix-postgres-api
export OTEL_EXPORTER_OTLP_ENDPOINT=https://scout-collector.example.com:4317
export RUST_LOG=info,sqlx=warn
export ENVIRONMENT=production
./target/release/api
```
```mdx-code-block
```
```bash showLineNumbers
docker compose up --build
docker compose logs -f api worker
docker compose down
```
```mdx-code-block
```
### Troubleshooting
#### Health Check Endpoint
```rust showLineNumbers title="src/handlers/health.rs"
use actix_web::{HttpResponse, web};
use serde_json::json;
use sqlx::{PgPool, Row};
pub async fn health_check(pool: web::Data) -> HttpResponse {
let db_status = sqlx::query("SELECT 1 as one")
.fetch_one(pool.get_ref())
.await
.map(|row: sqlx::postgres::PgRow| {
let _: i32 = row.get("one");
"healthy"
})
.unwrap_or("unhealthy");
if db_status == "healthy" {
HttpResponse::Ok().json(json!({
"status": "ok",
"database": db_status,
"service": "actix-postgres",
}))
} else {
HttpResponse::ServiceUnavailable().json(json!({
"status": "error",
"database": db_status,
}))
}
}
```
#### Debug Mode
Enable debug logging to troubleshoot instrumentation issues:
```bash showLineNumbers
export RUST_LOG=debug,opentelemetry=debug,tracing_opentelemetry=debug
cargo run --bin api
```
#### Common Issues
##### Issue: No traces appearing in Scout Dashboard
**Solutions:**
1. Verify Scout Collector endpoint is reachable:
```bash showLineNumbers
curl -v http://scout-collector:4317/v1/traces
```
2. Check environment variables:
```bash showLineNumbers
echo $OTEL_EXPORTER_OTLP_ENDPOINT
echo $OTEL_SERVICE_NAME
```
3. Enable debug logging and check for export errors
4. Verify network connectivity between your app and Scout Collector
##### Issue: Missing database query spans
**Solutions:**
1. Ensure SQLx logging level is set to at least `warn`:
```rust showLineNumbers
EnvFilter::new("info,sqlx=warn")
```
2. Verify you're using async SQLx methods that emit tracing spans
3. Check that `tracing-opentelemetry` layer is properly configured
##### Issue: Worker jobs not linked to original request trace
**Solutions:**
1. Verify `capture_trace_context()` is called during `enqueue()`
2. Check that the `trace_context` column exists in the jobs table
3. Ensure the worker calls `span.set_parent(parent_context)` before
processing
##### Issue: High memory usage
**Solutions:**
1. Reduce `max_queue_size` in batch processor configuration
2. Ensure spans are being exported successfully
3. Check for span attribute size limits
### Security Considerations
#### Protecting Sensitive Data
Avoid adding sensitive information to span attributes:
```rust showLineNumbers
// Bad - exposes sensitive data
tracing::info!(user.password = %password, "Login attempt");
// Good - uses safe identifiers
tracing::info!(user.id = user_id, user.role = %role, "Login attempt");
```
#### Sanitizing SQL Statements
SQLx parameterized queries prevent values from appearing in tracing spans:
```rust showLineNumbers
let user = sqlx::query_as!(
User,
"SELECT * FROM users WHERE email = $1 AND password_hash = $2",
email, password_hash
)
.fetch_optional(&pool)
.await?;
```
#### Compliance Considerations
For applications handling regulated data (GDPR, HIPAA, PCI-DSS):
- Never include personally identifiable information (PII) in spans
- Use hashed or anonymized user identifiers
- Implement data retention policies in Scout Dashboard
- Use parameterized queries to avoid logging sensitive values
- Audit span attributes regularly for sensitive data leaks
### Performance Considerations
#### Expected Performance Impact
OpenTelemetry instrumentation adds minimal overhead to Rust Actix Web
applications:
- **Average latency increase**: < 1ms per request
- **CPU overhead**: Less than 1% in production with batch processor
- **Memory overhead**: ~20-50MB depending on queue size and traffic
#### Optimization Best Practices
##### 1. Use Batch Processor in Production
The `BatchSpanProcessor` is used by default with `with_batch_exporter`:
```rust showLineNumbers
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(trace_exporter)
.build();
```
##### 2. Use Appropriate Log Levels
```rust showLineNumbers
// Production: minimal logging
let env_filter = EnvFilter::new("info,sqlx=warn");
// Development: verbose logging
let env_filter = EnvFilter::new("debug,sqlx=debug");
```
##### 3. Skip Health Check Endpoints
The OTel Collector filter processor removes health check spans:
```yaml showLineNumbers
processors:
filter/noisy:
traces:
span:
- 'IsMatch(name, ".*/api/health")'
```
### Frequently Asked Questions
#### Does OpenTelemetry impact Rust application performance?
OpenTelemetry adds approximately < 1ms of latency per request in typical
Actix Web applications. Rust's zero-cost abstractions and the efficient
`tracing` crate minimize overhead. With batch processing, the performance
impact is negligible for most production workloads.
#### What is the difference between Actix Web and Axum instrumentation?
Actix Web uses `tracing-actix-web` with `TracingLogger` middleware, while
Axum uses `tower-http` with `TraceLayer`. Actix Web shares state via
`web::Data`, while Axum uses `State` extractors. Error handling in
Actix Web uses the `ResponseError` trait, and authentication uses
`FromRequest` extractors instead of middleware layers.
#### Which Rust versions are supported?
OpenTelemetry Rust supports Rust 1.80+ with edition 2021 or 2024. Rust
1.92+ is recommended for optimal compatibility and performance.
#### Can I use OpenTelemetry with async Rust and Tokio?
Yes. The `tracing` crate handles async context propagation automatically,
and `tracing-opentelemetry` bridges tracing spans to OpenTelemetry. Use
`opentelemetry_sdk` with the `rt-tokio` feature for Tokio runtime support.
#### How do I trace async tasks spawned with tokio::spawn?
Use `tracing::Instrument` to propagate context to spawned tasks:
```rust showLineNumbers
use tracing::Instrument;
let span = tracing::info_span!("background_task");
tokio::spawn(async move {
// Work here is traced under the span
}.instrument(span));
```
#### How do I propagate traces to background job workers?
Store the W3C `traceparent` header in your job payload when enqueuing, then
extract it with `TraceContextPropagator` in the worker and set it as the
parent context using `span.set_parent()`. See the
[Job Queue](#job-queue-with-trace-propagation) section.
#### How do I handle multi-tenant applications?
Record the tenant as a field on the request span, then filter by it in
Scout:
```rust showLineNumbers
tracing::info_span!(
"request",
tenant.id = %tenant_id,
tenant.name = %tenant_name
);
```
#### How do I monitor SQLx connection pool health?
SQLx emits tracing spans for pool operations. Monitor these for connection
acquisition times and pool exhaustion. Configure the pool with appropriate
`acquire_timeout` and `max_connections` settings.
#### Can I include trace IDs in error responses?
Yes. Use the `get_trace_id()` helper function in your `ResponseError`
implementation to extract the current trace ID and include it in JSON error
responses. See the [Error Handling](#error-handling-with-trace-ids) section.
### What's Next?
#### Advanced Topics
- **[PostgreSQL Monitoring Best Practices](../../component/postgres.md)** -
Optimize database observability with connection pooling metrics
#### Scout Platform Features
- **[Creating Alerts](../../../guides/creating-alerts-with-logx.md)** - Set up
alerts for error rates, latency thresholds, and custom metrics
- **[Dashboard Creation](../../../guides/create-your-first-dashboard.md)** -
Build custom dashboards combining traces, metrics, and business KPIs
#### Deployment and Operations
- **[Docker Compose Setup](../../collector-setup/docker-compose-example.md)** -
Set up Scout Collector for local development
- **[Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md)** -
Production deployment
### Complete Example
#### Project Structure
```text
actix-postgres/
├── src/
│ ├── main.rs # API bootstrap with TracingLogger
│ ├── lib.rs # Library exports
│ ├── config.rs # Configuration
│ ├── error.rs # Error handling with trace IDs
│ ├── routes.rs # Route definitions
│ ├── bin/
│ │ └── worker.rs # Background job worker
│ ├── telemetry/
│ │ ├── init.rs # OTLP trace/log initialization
│ │ ├── metrics.rs # 12 custom metrics
│ │ └── mod.rs
│ ├── handlers/
│ │ ├── health.rs
│ │ ├── auth.rs
│ │ └── articles.rs
│ ├── services/
│ │ ├── auth.rs # #[instrument] on all methods
│ │ └── article.rs # #[instrument] on all methods
│ ├── repository/
│ │ ├── user.rs # SQLx with #[instrument]
│ │ ├── article.rs
│ │ └── favorite.rs
│ ├── middleware/
│ │ └── auth.rs # FromRequest extractors
│ ├── database/
│ │ └── pool.rs
│ ├── models/
│ │ ├── user.rs
│ │ └── article.rs
│ └── jobs/
│ ├── queue.rs # SKIP LOCKED + W3C trace propagation
│ └── notification.rs
├── config/
│ └── otel-config.yaml # Collector configuration
├── migrations/
│ └── 20260214000001_initial.sql
├── compose.yml
├── Dockerfile
├── Dockerfile.worker
├── Cargo.toml
└── Cargo.lock
```
#### Environment Variables
```bash showLineNumbers title=".env"
OTEL_SERVICE_NAME=actix-postgres-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4317
RUST_LOG=info,sqlx=warn
DATABASE_URL=postgres://postgres:postgres@localhost:5432/actix_postgres_app
JWT_SECRET=your-secret-key
```
This complete example is available in our
[GitHub examples repository](https://github.com/base-14/examples/tree/main/rust/actix-postgres).
### References
- [Official OpenTelemetry Rust Documentation](https://opentelemetry.io/docs/languages/rust/)
- [tracing-actix-web Crate](https://docs.rs/tracing-actix-web)
- [tracing-opentelemetry Crate](https://docs.rs/tracing-opentelemetry)
- [Actix Web Documentation](https://actix.rs/docs/)
### Related Guides
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) - Set
up collector for local development
- [Rust Custom Instrumentation](../custom-instrumentation/rust.md) - Manual
spans and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
---
## Angular OpenTelemetry - Browser Traces, Metrics, Logs
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
## Angular
:::note Running this in production
Storing and querying these sessions at production volume is what base14 Scout
does. [Check out Scout RUM](https://base14.io/scout/rum).
:::
### Introduction
Implement OpenTelemetry instrumentation for Angular applications to bring real
user monitoring (RUM), distributed tracing, and front-end observability to
single-page apps. This guide shows you how to auto-instrument document loads,
user interactions, and `fetch`/`XHR` calls in the browser, then propagate W3C
trace context to your backend so a single trace spans the browser, your API,
and your database.
It covers all three OpenTelemetry signals from the browser: **traces** (page
loads, interactions, route changes, and API calls), **metrics** (Core Web
Vitals as histograms), and **logs** (uncaught errors and failed requests as
ERROR records, correlated to the trace that caused them). The backend adds HTTP
and runtime metrics plus trace-correlated logs, so the browser and API tell one
story.
The example app is **Angular 22**, which is **zoneless by default**. The
instrumentation is identical on a zone.js application; the only difference is
how an interaction's asynchronous work is parented inside the browser. Both
variants are covered under
[Context Propagation](#context-propagation-zoneless-vs-zonejs). Whether you are
migrating from a commercial RUM product, debugging why a page feels slow, or
connecting a slow API call back to the click that caused it, this guide gives
you production-ready configuration.
:::tip TL;DR
Initialize the browser SDK in `main.ts` before `bootstrapApplication`. Register
the `WebTracerProvider` with the default `StackContextManager`
(`provider.register()` - no zone.js needed), and call
`setGlobalMeterProvider()` and `setGlobalLoggerProvider()` so metrics and logs
are not silently dropped. Add `@opentelemetry/auto-instrumentations-web` for
document-load, fetch, XHR, and interaction spans, set
`propagateTraceHeaderCorsUrls` to your API origin so the `traceparent` header
links the browser trace to your backend, record Core Web Vitals as metric
histograms, and emit uncaught errors as ERROR logs.
:::
### Who This Guide Is For
This documentation is designed for:
- **Angular developers**: adding browser tracing, metrics, and error logs to
standalone, signal-based Angular apps.
- **Front-end engineers**: connecting slow user interactions to the API and
database calls behind them with one end-to-end trace.
- **Full-stack teams**: correlating browser spans, Web Vitals, and error logs
with existing backend instrumentation across a shared trace id.
- **Platform teams**: standardizing web observability across several Angular
apps with a consistent, zoneless-ready setup.
- **SRE and DevOps**: deploying instrumented Angular SPAs behind nginx and a
collector with correct CORS in both directions.
### Overview
#### Prerequisites
Before starting, ensure you have:
- **Node.js 24.15+ or 26+** to build the app (the Angular 22 engines range).
- **Angular 20 or later** - the example uses `provideBrowserGlobalErrorListeners()`
(added in v20) and zoneless change detection (stable in v20, default in v21);
this guide uses **Angular 22**. The browser SDK setup itself works on any
standalone Angular (16+), but the error-handling wiring shown here needs v20+.
- **A Scout Collector** reachable from the browser over OTLP/HTTP, with traces,
metrics, and logs pipelines.
- See
[Docker Compose Setup](../../collector-setup/docker-compose-example.md)
for local development.
- See [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md)
for production.
- A backend API that accepts cross-origin requests and echoes back CORS
headers (this guide uses Express + Postgres).
- Basic understanding of OpenTelemetry concepts (traces, spans, metrics, logs,
attributes).
#### Compatibility Matrix
| Component | Minimum Version | Recommended |
| -------------------------------------------- | --------------- | ----------- |
| Angular | 20.0.0 | 22.0.4 |
| Node.js (build) | 24.15.0 | 26.x |
| @opentelemetry/api | 1.9.0 | 1.9.1 |
| @opentelemetry/api-logs | 0.200.0 | 0.219.0 |
| @opentelemetry/sdk-trace-web | 2.0.0 | 2.8.0 |
| @opentelemetry/sdk-metrics | 2.0.0 | 2.8.0 |
| @opentelemetry/sdk-logs | 0.200.0 | 0.219.0 |
| @opentelemetry/auto-instrumentations-web | 0.50.0 | 0.64.0 |
| @opentelemetry/exporter-trace-otlp-http | 0.200.0 | 0.219.0 |
| @opentelemetry/exporter-metrics-otlp-http | 0.200.0 | 0.219.0 |
| @opentelemetry/exporter-logs-otlp-http | 0.200.0 | 0.219.0 |
| web-vitals | 4.0.0 | 5.3.0 |
| opentelemetry-collector-contrib | 0.120.0 | 0.153.0 |
Angular 21 and later ship **zoneless** by default (`ng new` no longer adds
zone.js). The setup below assumes zoneless; the zone.js opt-in is a three-line
delta shown later.
#### Instrumented Components
| Signal | Source | Emitted as |
| ------- | ------------------------------------------ | ------------------ |
| Traces | `instrumentation-document-load` | document-load span |
| Traces | `instrumentation-user-interaction` | click/submit span |
| Traces | `instrumentation-fetch` / `-xml-http-request` | HTTP client span |
| Traces | Custom `Router.events` subscription | `router.navigation` span |
| Metrics | `web-vitals` library | histograms (`web_vitals.*`) |
| Logs | Custom Angular `ErrorHandler` | ERROR log (best-effort) |
| Logs | Custom `HttpClient` interceptor | ERROR log (trace-correlated) |
The backend (Express + Node SDK) adds `http.server.request.duration` and
runtime metrics plus trace-correlated `pino` logs, so the browser and API share
one trace id across all three signals.
A complete, runnable version of everything in this guide lives in
[base-14/examples/nodejs/angular-fullstack-otel](https://github.com/base-14/examples/tree/main/nodejs/angular-fullstack-otel).
### Installation
Install the browser SDK for all three signals, the web auto-instrumentations,
the OTLP/HTTP exporters, and the Web Vitals library.
```mdx-code-block
```
```bash
npm install \
@opentelemetry/api \
@opentelemetry/api-logs \
@opentelemetry/sdk-trace-web \
@opentelemetry/sdk-metrics \
@opentelemetry/sdk-logs \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/exporter-logs-otlp-http \
@opentelemetry/resources \
@opentelemetry/semantic-conventions \
@opentelemetry/instrumentation \
@opentelemetry/auto-instrumentations-web \
web-vitals
```
```mdx-code-block
```
```bash
yarn add \
@opentelemetry/api \
@opentelemetry/api-logs \
@opentelemetry/sdk-trace-web \
@opentelemetry/sdk-metrics \
@opentelemetry/sdk-logs \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/exporter-logs-otlp-http \
@opentelemetry/resources \
@opentelemetry/semantic-conventions \
@opentelemetry/instrumentation \
@opentelemetry/auto-instrumentations-web \
web-vitals
```
```mdx-code-block
```
```bash
pnpm add \
@opentelemetry/api \
@opentelemetry/api-logs \
@opentelemetry/sdk-trace-web \
@opentelemetry/sdk-metrics \
@opentelemetry/sdk-logs \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/exporter-logs-otlp-http \
@opentelemetry/resources \
@opentelemetry/semantic-conventions \
@opentelemetry/instrumentation \
@opentelemetry/auto-instrumentations-web \
web-vitals
```
```mdx-code-block
```
The OpenTelemetry JS packages move on a few separate version lines: the SDK
packages `sdk-trace-web`, `sdk-metrics`, and `resources` are on the stable `2.x`
line (2.8.0), `semantic-conventions` is on the `1.x` line (1.41.1), and
`api-logs`, `sdk-logs`, `instrumentation`, plus the OTLP exporters share the
`0.2xx` experimental line (0.219.0), while `auto-instrumentations-web` tracks
its own contrib line (0.64.0). Keep `@opentelemetry/api` to a single version
(1.9.1) so the bundle does not pull duplicate copies.
### Configuration
Telemetry settings differ between a development build (`ng serve`) and a
production build (`ng build`). Angular's environment files are the simplest
place to keep them, but a runtime config file or container environment also
works.
```mdx-code-block
```
```typescript title="src/environments/environment.ts" showLineNumbers
// Production build config (used by `ng build`). The browser runs on the host in
// every mode of this example, so the collector/API are reached via localhost.
// The whole local stack runs as a single `development` environment (matching the
// backend DEPLOY_ENV and the collector's SCOUT_ENVIRONMENT default) so one trace
// carries one environment. For a real deployment, set this to `production` and
// override the endpoints (e.g. a runtime assets/config.json).
export const environment = {
production: true,
otelServiceName: 'angular-browser',
deploymentEnvironment: 'development',
otelCollectorUrl: 'http://localhost:4318',
apiBaseUrl: 'http://localhost:3000/api',
// Only attach `traceparent` to our own API (don't leak trace headers cross-site).
apiTraceUrls: [/^http:\/\/localhost:3000/] as RegExp[],
};
```
Angular swaps in `environment.development.ts` during `ng serve` via the
`fileReplacements` entry in `angular.json`, so dev and prod can point at
different collector and API origins without code changes.
```mdx-code-block
```
For container images that must be configurable without a rebuild, load a JSON
file from `assets/` at startup instead of compiling values into the bundle.
```json title="src/assets/otel-config.json"
{
"otelServiceName": "angular-browser",
"deploymentEnvironment": "production",
"otelCollectorUrl": "https://collector.example.com",
"apiBaseUrl": "https://api.example.com"
}
```
For this variant, refactor `initBrowserTelemetry` to accept the config object
(instead of reading the compiled-in `environment`), then fetch the file and pass
it in before bootstrap:
```typescript title="src/main.ts (runtime variant)"
// initBrowserTelemetry(cfg: OtelConfig) reads endpoints from cfg, not environment
const cfg = await fetch('/assets/otel-config.json').then((r) => r.json());
initBrowserTelemetry(cfg);
```
Fetch the config before bootstrap so the SDK starts with the right endpoints. The
trade-off is one extra request before bootstrap.
```mdx-code-block
```
The browser exports straight to the collector, so the collector decides which
SPA origins it trusts. Set the OTLP HTTP receiver's CORS allow-list to every
origin the app is served from, and run a pipeline for each signal.
```yaml title="config/otel-config.yaml"
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
cors:
allowed_origins:
- http://localhost:4200 # ng serve (dev)
- http://localhost:8080 # nginx container
allowed_headers:
- "*"
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [otlp_http/b14, debug]
metrics:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [otlp_http/b14, debug]
logs:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [otlp_http/b14, debug]
```
```mdx-code-block
```
### Production Configuration
#### Build-time vs runtime configuration
Compiling endpoints into the bundle (the `environment.ts` approach) is simplest
and fastest, but it bakes the collector and API URLs into the image. If you
ship one image to several environments, prefer the runtime `config.json`
approach so the same artifact reads its endpoints at startup.
#### Serving the built app
The `@angular/build:application` builder emits the browser bundle under
`dist//browser`. Serve it from any static host. With nginx, add an SPA
fallback so deep links resolve to `index.html`:
```nginx title="frontend/nginx.conf"
server {
listen 80;
# SPA fallback: Angular handles client-side routes, so unknown paths must
# return index.html rather than 404 (deep links like /items, /about).
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}
```
```dockerfile title="frontend/Dockerfile"
# Build the Angular bundle, then serve the static output from nginx.
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
```
#### Export cadence and batching
Each signal exports off the interaction path on its own schedule. The
`BatchSpanProcessor` and `BatchLogRecordProcessor` buffer spans and log records
and flush on a timer; the `PeriodicExportingMetricReader` exports metrics every
`exportIntervalMillis` (10 seconds in the example). For chatty UIs, raise the
span processor's `maxQueueSize` and `maxExportBatchSize` so bursts are not
dropped. Because Core Web Vitals and last-moment errors are reported as the page
is hidden, force a flush of all three providers on `visibilitychange` and
`pagehide` (shown in the bootstrap below) so that data is not lost with the tab.
#### Reverse-proxy alternative to collector CORS
Instead of opening CORS on the collector, you can serve the collector under the
same origin as the SPA through a reverse proxy (for example, proxy
`/v1/traces`, `/v1/metrics`, and `/v1/logs` on the app's domain to the
collector). Same-origin export removes the browser preflight entirely. Point
`otelCollectorUrl` at the app origin and drop the `cors` block from the
receiver.
### Angular-Specific Features
#### Initialize before bootstrap
Call the SDK setup in `main.ts` **before** `bootstrapApplication`, so the
document-load span and the earliest interactions are captured. The Angular
Router is not available here (it only exists after dependency injection is up),
so router tracing is wired separately from the root component.
```typescript title="src/main.ts" showLineNumbers
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
import { initBrowserTelemetry } from './app/telemetry/browser-telemetry';
initBrowserTelemetry();
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
```
#### HttpClient uses the fetch backend
In Angular 22, `fetch` is the default `HttpClient` backend and `withFetch()` is
deprecated, so `provideHttpClient()` alone routes requests through it. Because
HttpClient calls go through `fetch()`, the OpenTelemetry fetch instrumentation
captures them and injects the `traceparent` header automatically. Register the custom
`ErrorHandler` (for uncaught errors) and the HTTP error interceptor (for failed
requests) here too - both emit ERROR logs, covered under
[Errors as logs](#errors-as-logs).
```typescript title="src/app/app.config.ts" showLineNumbers
import {
ApplicationConfig,
ErrorHandler,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { TelemetryErrorHandler } from './telemetry/error-handler';
import { errorLogInterceptor } from './telemetry/error-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
{ provide: ErrorHandler, useClass: TelemetryErrorHandler },
provideRouter(routes),
// Fetch backend is the v22 default (OTel fetch instrumentation captures it +
// attaches traceparent); interceptor emits a correlated log on failure.
provideHttpClient(withInterceptors([errorLogInterceptor])),
],
};
```
#### Router navigation spans
The web auto-instrumentations cover document load, fetch, XHR, and user
interactions, but **not** Angular's client-side Router. Subscribe to
`Router.events`, filter for `NavigationEnd`, and emit a span per navigation.
```typescript title="src/app/telemetry/router-tracing.ts" showLineNumbers
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';
import { trace } from '@opentelemetry/api';
// The OTel web auto-instrumentations cover document-load, fetch/XHR, and user
// interactions, but not Angular's client-side Router. Subscribe to NavigationEnd
// so each SPA route change shows up as its own span.
export function initRouterTracing(router: Router): void {
const tracer = trace.getTracer('angular-router');
router.events
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
.subscribe((e) => {
const span = tracer.startSpan('router.navigation');
span.setAttributes({
'route.path': e.urlAfterRedirects,
'nav.id': e.id,
'page.url': window.location.href,
});
span.end();
});
}
```
Wire it from the root component, where the `Router` is available via injection:
```typescript title="src/app/app.ts" showLineNumbers
import { Component, inject } from '@angular/core';
import { RouterOutlet, RouterLink, Router } from '@angular/router';
import { initRouterTracing } from './telemetry/router-tracing';
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
constructor() {
initRouterTracing(inject(Router));
}
}
```
#### Context Propagation (zoneless vs zone.js)
The distributed trace - browser to API to database - links through the injected
`traceparent` header, which does **not** depend on the context manager. Only
in-browser parenting of an interaction's asynchronous work differs between the
two modes.
```mdx-code-block
```
Angular 22 ships zoneless, so there is no zone.js to hook. Register the tracer
provider with no arguments to install the default `StackContextManager` and the
W3C trace context propagator. Metrics and logs have no `register()` equivalent,
so their providers are set global explicitly - miss that step and every metric
and log is silently dropped (see
[Why metrics/logs go missing](#no-metrics-or-logs-reach-the-collector)).
```typescript title="src/app/telemetry/browser-telemetry.ts" showLineNumbers
import { WebTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-web';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import {
MeterProvider,
PeriodicExportingMetricReader,
AggregationType,
type ViewOptions,
} from '@opentelemetry/sdk-metrics';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { LoggerProvider, BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { metrics } from '@opentelemetry/api';
import { logs } from '@opentelemetry/api-logs';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { getWebAutoInstrumentations } from '@opentelemetry/auto-instrumentations-web';
import { environment } from '../../environments/environment';
import { setupWebVitals } from './web-vitals';
let initialized = false;
// Per-vital histogram bucket boundaries - see "Core Web Vitals as metrics".
const VITAL_VIEWS: ViewOptions[] = [ /* ... */ ];
// Call once before Angular bootstraps so document-load + early interactions are
// captured.
export function initBrowserTelemetry(): void {
if (initialized || typeof window === 'undefined') {
return;
}
initialized = true;
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: environment.otelServiceName,
[ATTR_SERVICE_VERSION]: '1.0.0',
'deployment.environment.name': environment.deploymentEnvironment,
environment: environment.deploymentEnvironment,
});
// --- Traces ---
const tracerProvider = new WebTracerProvider({
resource,
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter({ url: `${environment.otelCollectorUrl}/v1/traces` }),
),
],
});
// Zoneless: installs the StackContextManager + W3C propagator and sets the
// global TracerProvider.
tracerProvider.register();
// --- Metrics ---
const meterProvider = new MeterProvider({
resource,
readers: [
new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${environment.otelCollectorUrl}/v1/metrics` }),
exportIntervalMillis: 10000,
}),
],
views: VITAL_VIEWS,
});
// No register() sugar for metrics/logs: without setGlobal*, getMeter/getLogger
// return Noop and every data point is silently dropped.
metrics.setGlobalMeterProvider(meterProvider);
// --- Logs ---
const loggerProvider = new LoggerProvider({
resource,
processors: [
new BatchLogRecordProcessor(
new OTLPLogExporter({ url: `${environment.otelCollectorUrl}/v1/logs` }),
),
],
});
logs.setGlobalLoggerProvider(loggerProvider);
// Vitals and last-moment error logs emit as the page hides; flush all signals
// then so nothing is lost with the tab.
const flush = (): void => {
void tracerProvider.forceFlush();
void meterProvider.forceFlush();
void loggerProvider.forceFlush();
};
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flush();
}
});
window.addEventListener('pagehide', flush);
registerInstrumentations({
instrumentations: [
getWebAutoInstrumentations({
'@opentelemetry/instrumentation-user-interaction': {
eventNames: ['click', 'submit'],
},
'@opentelemetry/instrumentation-fetch': {
propagateTraceHeaderCorsUrls: environment.apiTraceUrls,
},
'@opentelemetry/instrumentation-xml-http-request': {
propagateTraceHeaderCorsUrls: environment.apiTraceUrls,
},
}),
],
});
setupWebVitals();
}
```
The dual-key resource attributes (`deployment.environment.name` plus a
lowercase `environment`) keep the SDK aligned with the current semantic
conventions while satisfying dashboards that filter on the short key. The same
`resource` is shared across all three providers so traces, metrics, and logs
carry identical service identity.
```mdx-code-block
```
If your app still uses zone.js (Angular 20 or earlier, or an explicit opt-in),
add the `ZoneContextManager` so an interaction span becomes the parent of the
async work it triggers. This is **not** part of the zoneless example - it is the
three-line delta for a zone-based app. Metrics and logs are unchanged.
```bash
npm install @opentelemetry/context-zone zone.js
```
```typescript title="src/app/telemetry/browser-telemetry.ts (zone-based delta)"
import 'zone.js';
import { ZoneContextManager } from '@opentelemetry/context-zone';
// ...build the tracer provider as above, then register with the zone manager:
tracerProvider.register({ contextManager: new ZoneContextManager() });
```
The difference shows up only across an asynchronous boundary. Work that runs
synchronously inside an interaction handler nests under it either way - in this
example the `fetch` fires synchronously on `subscribe`, so it parents correctly
under `items.load` and `click` even when zoneless (verified in the span tree
below). `ZoneContextManager` additionally propagates context across async gaps
(a later microtask, `setTimeout`, or an awaited call), so a `fetch` issued after
such a gap still nests under the interaction. Without it, that deferred `fetch`
starts a new root unless you wrap it in `startActiveSpan`. Either way the
distributed link to the API is unchanged - it rides the `traceparent` header.
```mdx-code-block
```
### Custom Instrumentation
#### Manual spans around HttpClient
Wrap a request in a manual span when you want application-level timing
(`items.load`) around the auto-generated `fetch` span. `startActiveSpan` makes
the manual span the active parent, so the fetch nests under it even without
zone.js because the subscribe fires synchronously. The second button
(`items.load.missing`) fires a request that 404s, which drives the correlated
error log shown later.
```typescript title="src/app/items/items.ts" showLineNumbers
import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { environment } from '../../environments/environment';
interface Item {
id: number;
name: string;
price: string;
}
@Component({
selector: 'app-items',
imports: [],
templateUrl: './items.html',
styleUrl: './items.css',
})
export class Items {
private http = inject(HttpClient);
items = signal- ([]);
loadItems(): void {
const tracer = trace.getTracer('angular-items');
// startActiveSpan makes items.load the active parent; the fetch fired
// synchronously on subscribe nests under it even without zone.js.
tracer.startActiveSpan('items.load', (span) => {
this.http.get
- (`${environment.apiBaseUrl}/items`).subscribe({
next: (data) => {
this.items.set(data);
span.end();
},
error: (err) => {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
},
});
});
}
// Demo: failing request inside an active span -> interceptor emits a correlated log.
triggerApiError(): void {
const tracer = trace.getTracer('angular-items');
tracer.startActiveSpan('items.load.missing', (span) => {
this.http.get
- (`${environment.apiBaseUrl}/missing`).subscribe({
next: () => span.end(),
error: (err) => {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
},
});
});
}
// Demo: uncaught throw -> ErrorHandler emits a best-effort (uncorrelated) log.
triggerError(): void {
throw new Error('Demo: uncaught error from Items page');
}
}
```
#### Core Web Vitals as metrics
Record Core Web Vitals as **metric histograms**, one instrument per vital.
The `web-vitals` library reports CLS, INP, LCP, FCP, and TTFB through callbacks;
each callback records a measurement tagged with the page path and the library's
rating (`good` / `needs-improvement` / `poor`).
```typescript title="src/app/telemetry/web-vitals.ts" showLineNumbers
import { metrics, type Histogram } from '@opentelemetry/api';
import { onCLS, onFCP, onINP, onLCP, onTTFB, type Metric } from 'web-vitals';
// Web Vitals as histograms (not spans) so RUM can report p75/p95; one instrument
// per vital for per-vital bucket Views (see browser-telemetry.ts).
export function setupWebVitals(): void {
// Acquire after the global MeterProvider is set (else Noop meter, dropped).
const meter = metrics.getMeter('web-vitals', '1.0.0');
const cls = meter.createHistogram('web_vitals.cls', {
unit: '1',
description: 'Cumulative Layout Shift',
});
const lcp = meter.createHistogram('web_vitals.lcp', {
unit: 'ms',
description: 'Largest Contentful Paint',
});
const inp = meter.createHistogram('web_vitals.inp', {
unit: 'ms',
description: 'Interaction to Next Paint',
});
const fcp = meter.createHistogram('web_vitals.fcp', {
unit: 'ms',
description: 'First Contentful Paint',
});
const ttfb = meter.createHistogram('web_vitals.ttfb', {
unit: 'ms',
description: 'Time to First Byte',
});
const record =
(histogram: Histogram) =>
(metric: Metric): void => {
histogram.record(metric.value, {
'web_vital.rating': metric.rating,
'page.path': window.location.pathname,
});
};
onCLS(record(cls));
onLCP(record(lcp));
onINP(record(inp));
onFCP(record(fcp));
onTTFB(record(ttfb));
}
```
Default histogram buckets (0, 5, 10, 25, ...) fit neither a CLS score (roughly
0 to 1) nor millisecond timings, so give each instrument its own bucket
boundaries with a `View`. These are the `VITAL_VIEWS` elided from the bootstrap
above:
```typescript title="src/app/telemetry/browser-telemetry.ts (VITAL_VIEWS)"
const VITAL_VIEWS: ViewOptions[] = [
{
instrumentName: 'web_vitals.cls',
aggregation: {
type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM,
options: { boundaries: [0.05, 0.1, 0.15, 0.25, 0.5, 1] },
},
},
{
instrumentName: 'web_vitals.lcp',
aggregation: {
type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM,
options: { boundaries: [500, 1000, 1500, 2000, 2500, 3000, 4000, 5000, 7500, 10000] },
},
},
{
instrumentName: 'web_vitals.inp',
aggregation: {
type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM,
options: { boundaries: [50, 100, 150, 200, 300, 500, 750, 1000] },
},
},
{
instrumentName: 'web_vitals.fcp',
aggregation: {
type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM,
options: { boundaries: [500, 1000, 1500, 1800, 2500, 3000, 4000, 6000] },
},
},
{
instrumentName: 'web_vitals.ttfb',
aggregation: {
type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM,
options: { boundaries: [100, 200, 400, 600, 800, 1200, 1800, 3000] },
},
},
];
```
##### Web Vitals: spans vs metrics
An earlier version of this example recorded each vital as a short-lived span
(`web_vital.lcp`, and so on). That works and puts page performance in the trace
stream, but it is the wrong shape for real user monitoring:
- **Core Web Vitals are distributions, not events.** Google scores a site at
the **p75** of each vital across real sessions. A metric `Histogram`
aggregates measurements into buckets in the SDK, so the backend computes p75
and p95 directly; a pile of individual spans has to be aggregated downstream
before it means anything.
- **Spans are per-event and unbounded.** One span per vital per page view is a
lot of zero-duration spans that clutter traces and cost storage, without
giving you the percentile view you actually want.
- **Bucketing is explicit and cheap.** Per-vital `View` boundaries (a CLS score
needs different buckets than an LCP in milliseconds) keep the histogram
meaningful and small.
Use a **span** (or a span event) when you want a single vital tied to one
specific trace for debugging a single session. Use a **metric histogram** - as
this example does - for fleet-wide RUM percentiles. The two are not exclusive,
but percentiles are the common need, so metrics are the default here.
#### Errors as logs
Browser errors are emitted as **ERROR logs**, on two paths. Failed HTTP
requests go through an `HttpClient` interceptor that captures the active trace
context and re-enters it when it emits, so the log carries the trace id of the
request that failed. Everything else - uncaught throws, and the global `error`
and `unhandledrejection` events - lands in a custom `ErrorHandler`, which emits
a best-effort log (usually without a trace id in a zoneless app, because the
originating span has already unwound).
```typescript title="src/app/telemetry/error-interceptor.ts" showLineNumbers
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { context } from '@opentelemetry/api';
import { logs, SeverityNumber } from '@opentelemetry/api-logs';
import { catchError, throwError } from 'rxjs';
// Trace-correlated ERROR log for every failed HttpClient request. catchError runs
// async, after the zoneless context has unwound, so we capture the context here
// (synchronous, caller's span still active) and re-enter it at emit - emit()
// stamps the trace id from the active context, so this is what keeps the log
// correlated.
export const errorLogInterceptor: HttpInterceptorFn = (req, next) => {
const activeContext = context.active();
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
const logger = logs.getLogger('browser-http');
context.with(activeContext, () => {
logger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: 'ERROR',
body: `HTTP ${req.method} ${req.urlWithParams} failed: ${err.status} ${err.message}`,
attributes: {
'http.request.method': req.method,
'url.full': req.urlWithParams,
'http.response.status_code': err.status,
'error.type': err.name,
},
});
});
return throwError(() => err);
}),
);
};
```
```typescript title="src/app/telemetry/error-handler.ts" showLineNumbers
import { ErrorHandler, Injectable } from '@angular/core';
import { logs, SeverityNumber } from '@opentelemetry/api-logs';
// Single capture point for uncaught errors in a zoneless app (Angular errors +
// the window error/unhandledrejection events via provideBrowserGlobalErrorListeners).
// Correlation is best-effort: the span has usually unwound by the time an error
// lands here, so logs may have no trace id. For correlated HTTP-failure logs see
// error-interceptor.
@Injectable()
export class TelemetryErrorHandler implements ErrorHandler {
private logger = logs.getLogger('browser-errors');
handleError(error: unknown): void {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: 'ERROR',
body: err.message,
attributes: {
'exception.type': err.name,
'exception.message': err.message,
'exception.stacktrace': err.stack ?? '',
'page.path': window.location.pathname,
},
});
console.error(error);
}
}
```
#### Reading the active trace id
To surface the current trace id (for a support widget or a "copy trace id"
button), read it from the active span context:
```typescript
import { trace } from '@opentelemetry/api';
const span = trace.getActiveSpan();
const traceId = span?.spanContext().traceId;
```
### Backend Signals
The Express API is auto-instrumented with the OpenTelemetry Node SDK, which
adds the API leg of every trace plus its own metrics and logs. Two details make
the browser and API tell one story:
- **Metrics**: enabling a `PeriodicExportingMetricReader` turns on
`http.server.request.duration` and runtime metrics automatically. Set
`OTEL_SEMCONV_STABILITY_OPT_IN=http` so the metric uses the stable name
(`http.server.request.duration`) rather than the deprecated
`http.server.duration`.
- **Logs**: `pino` records are auto-bridged to OTLP by the Node
auto-instrumentations, with `trace_id` and `span_id` injected, so a backend
log line links to the exact request span.
```typescript title="backend/src/instrumentation.ts" showLineNumbers
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
const env = process.env.DEPLOY_ENV || 'development';
const base = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318';
// http.server.request.duration + runtime-node metrics and pino log-bridging are
// automatic once a reader/processor exists. Stable metric name needs
// OTEL_SEMCONV_STABILITY_OPT_IN=http (compose.yaml).
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: 'angular-items-api',
[ATTR_SERVICE_VERSION]: '1.0.0',
'deployment.environment.name': env,
environment: env,
}),
traceExporter: new OTLPTraceExporter({ url: `${base}/v1/traces` }),
metricReaders: [
new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${base}/v1/metrics` }),
exportIntervalMillis: 10000,
}),
],
logRecordProcessors: [
new BatchLogRecordProcessor(new OTLPLogExporter({ url: `${base}/v1/logs` })),
],
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-runtime-node': { enabled: true },
}),
],
});
sdk.start();
```
Preload the SDK with `node -r ./dist/instrumentation.js ./dist/server.js` so it
patches Express, `pg`, and `pino` before they are imported. The API must also
allow the `traceparent` and `tracestate` request headers so the browser's trace
context survives the cross-origin call:
```typescript title="backend/src/server.ts" showLineNumbers
import express from 'express';
import cors from 'cors';
import pino from 'pino';
import { pool } from './db';
const app = express();
// Auto-bridged + trace-correlated by instrumentation-pino (via the -r preload).
const log = pino();
// allowedHeaders must include traceparent/tracestate so the browser fetch can
// propagate trace context and stay in one trace with the server span.
app.use(
cors({
origin: ['http://localhost:4200', 'http://localhost:8080'],
allowedHeaders: ['Content-Type', 'traceparent', 'tracestate'],
}),
);
app.get('/api/items', async (_req, res) => {
const { rows } = await pool.query('SELECT id, name, price FROM items ORDER BY id');
log.info({ count: rows.length }, 'served items');
res.json(rows);
});
app.get('/healthz', (_req, res) => {
res.json({ ok: true });
});
const port = Number(process.env.PORT || 3000);
app.listen(port, () => log.info(`angular-items-api listening on :${port}`));
```
### Running Your Application
#### Development
Run the collector and API (see the example's `compose.yaml`), then start the
Angular dev server:
```bash
ng serve
```
Open `http://localhost:4200`, interact with the page, and watch signals arrive
at the collector. The dev origin (`http://localhost:4200`) must be in the
collector's CORS allow-list and the API's CORS policy.
#### Full stack with Docker Compose
```bash
docker compose up --build
```
Open `http://localhost:8080/items`, click **Load items**, navigate between
routes, and click **Trigger API error** and **Trigger error**.
#### Expected span tree
A single "Load items" click produces one trace across three services:
```text
click (angular-browser) (user-interaction instrumentation)
└─ items.load (manual span in loadItems())
└─ HTTP GET http://localhost:3000/api/items (browser fetch instrumentation)
└─ GET /api/items (angular-items-api, Express)
└─ pg.query:SELECT items (Postgres)
```
#### Verifying in the collector
With the collector's `debug` exporter on, the browser fetch span and the
backend spans share one trace id:
```text
Span #0
Trace ID : 5d6d4c8814af19ee...
Name : HTTP GET
-> http.url: Str(http://localhost:3000/api/items)
-> service.name: Str(angular-browser)
Span #1
Trace ID : 5d6d4c8814af19ee...
Name : GET /api/items
-> service.name: Str(angular-items-api)
Span #2
Trace ID : 5d6d4c8814af19ee...
Name : pg.query:SELECT items
```
Alongside the traces you should see:
- **Metrics**: `web_vitals.{cls,fcp,lcp,ttfb}` histograms from the browser
(INP needs a real measured interaction, so it may not appear under a scripted
drive), plus `http.server.request.duration` and `runtime.node.*` from the
backend.
- **Logs**: the backend `served items` `pino` record carrying `trace_id` /
`span_id`; a browser interceptor ERROR log with a non-zero trace id after
**Trigger API error**; and an `ErrorHandler` ERROR log (best-effort, no trace
id) after **Trigger error**.
- One `router.navigation` span per client-side route change.
### Troubleshooting
#### The browser span and the API span are in different traces
The `traceparent` header is not making it to the API. This needs two fixes,
because CORS is involved in both directions:
1. **SDK side**: add the API origin to `propagateTraceHeaderCorsUrls` on the
fetch and XHR instrumentations. Without it, OpenTelemetry will not inject
`traceparent` on cross-origin requests.
2. **API side**: allow the `traceparent` and `tracestate` request headers in
the API's CORS policy. If the API does not echo them back as allowed
headers, the browser strips them before the request leaves.
```typescript title="backend CORS (Express example)"
app.use(cors({
origin: ['http://localhost:4200', 'http://localhost:8080'],
allowedHeaders: ['Content-Type', 'traceparent', 'tracestate'],
}));
```
#### Backend-only OPTIONS traces with no browser parent
Because the SPA (`:8080`) and API (`:3000`) are cross-origin, every API call
fires a CORS **preflight `OPTIONS`** first, and browsers do not allow custom
headers such as `traceparent` on a preflight. Each preflight therefore starts
its own backend-only trace with no browser parent. This is expected: the actual
`GET`/`POST` request carries the `traceparent` and links the browser to the API.
If a trace looks "browser-less", check that you are looking at the real request,
not its `OPTIONS` preflight (or a browser-only trace such as `documentLoad`).
#### No metrics or logs reach the collector
Traces call `provider.register()`, which sets the global tracer, but metrics and
logs have no such sugar. If you skip `metrics.setGlobalMeterProvider()` or
`logs.setGlobalLoggerProvider()`, then `getMeter()` / `getLogger()` return a
No-op implementation and every measurement or log is silently dropped - no
error, just nothing. Set both globals during bootstrap, and acquire meters and
loggers **after** that (as `setupWebVitals()` does).
#### A deferred interaction span does not parent its async fetch (zoneless)
A synchronous fetch nests fine without zone.js (see the span tree above). But if
the request is issued after an async gap - a `setTimeout`, a later microtask, or
an `await` before the call - the active-context link is lost in zoneless mode and
the fetch starts a new root. If you need that deferred work parented, either wrap
it in a manual `startActiveSpan` (as the `items.load` example does) or opt into
`ZoneContextManager`. The distributed link to the API is unaffected either way.
#### No spans at all, or the document-load span is missing
`initBrowserTelemetry()` is not running early enough, or not at all. Confirm it
is called in `main.ts` **before** `bootstrapApplication`. If only the
document-load span is missing, the SDK is starting after the page has already
loaded - move the call ahead of bootstrap.
#### Signals are created but never reach the collector
Check the collector's OTLP HTTP receiver CORS. A browser preflight (`OPTIONS`)
that the collector rejects shows up as a CORS error in the browser console and
nothing in the collector. Add the SPA origin to `allowed_origins`, confirm
`otelCollectorUrl` points at the collector's `:4318` endpoint (the exporters
append `/v1/traces`, `/v1/metrics`, `/v1/logs`), and confirm the collector has a
pipeline for each signal.
### Security Considerations
- **No PII in span attributes, metric labels, or log bodies**: browser
telemetry is visible to anyone with collector access. Do not put emails,
tokens, or session ids into attributes, and avoid query strings that carry
secrets in `http.url` or `url.full`.
- **Scope CORS origins**: list exact SPA origins in the collector receiver and
the API. Never use `*` for `allowed_origins` in production - it lets any site
post telemetry to your collector.
- **Scope trace propagation**: keep `propagateTraceHeaderCorsUrls` limited to
your own API origins so the `traceparent` header is never sent to third-party
domains.
- **Header filtering**: if you add request/response headers to spans, allow-list
the safe ones rather than capturing everything (avoid `authorization`,
`cookie`, `set-cookie`).
- **Transport security**: serve the collector endpoint over HTTPS in production
so telemetry is encrypted in transit.
### Performance Considerations
- **Runtime overhead**: span creation and histogram recording are cheap (object
allocation plus a timestamp). Every signal exports off the interaction path -
spans and logs through batch processors, metrics through a periodic reader
(10 s here) - so user-facing latency is unaffected.
- **Batch tuning**: raise the span processor's `maxQueueSize` and
`maxExportBatchSize` for chatty UIs so interaction-span bursts are not dropped;
lower a flush interval if you want data to appear sooner while debugging.
- **What not to span**: avoid creating a span per animation frame, scroll event,
or mousemove. Restrict `instrumentation-user-interaction` to meaningful events
(`click`, `submit`) as the example does.
- **Web Vitals cost**: the `web-vitals` callbacks fire a handful of times per
page, and each records a single histogram measurement - negligible.
### FAQ
#### Does OpenTelemetry work with zoneless Angular?
Yes. Angular 21 and later are zoneless by default. Register the
`WebTracerProvider` with the default `StackContextManager` by calling
`provider.register()` with no arguments. The browser-to-API trace links through
the `traceparent` header, which is independent of the context manager, so
distributed tracing works the same with or without zone.js.
#### Do I need zone.js for OpenTelemetry in Angular?
No. zone.js is optional in modern Angular. You only need the
`ZoneContextManager` (and zone.js) if you want an interaction's asynchronous
work nested under the interaction span inside the browser. Distributed tracing
to your API and database works without zone.js.
#### Why is my Angular browser span in a different trace than my API span?
The `traceparent` header is not reaching your API. Either the request URL is not
in `propagateTraceHeaderCorsUrls` (so the header is not injected), or the API's
CORS policy does not allow the `traceparent` and `tracestate` headers (so the
browser strips them). Fix both. If instead you are looking at a lone backend
`OPTIONS` trace, that is the CORS preflight - preflights cannot carry
`traceparent`, so the real request is the one that links.
#### Should I record Core Web Vitals as spans or metrics in Angular?
Record them as metric histograms. Core Web Vitals are fleet-wide distributions
(Google scores at p75), and a `Histogram` aggregates into buckets so the backend
computes p75 and p95 without storing every event. A short-lived span per vital
is fine for debugging a single session but does not aggregate into percentiles,
so metrics are the better fit for real user monitoring.
#### How do I trace Angular route changes?
The web auto-instrumentations do not cover Angular's Router. Subscribe to
`Router.events`, filter for `NavigationEnd`, and emit a span per navigation with
the resolved route path as an attribute.
#### How do I capture uncaught Angular errors with OpenTelemetry?
Emit them as ERROR logs. Provide a custom `ErrorHandler` that calls
`logger.emit` with `SeverityNumber.ERROR`; `provideBrowserGlobalErrorListeners()`
forwards the window `error` and `unhandledrejection` events into it, so a plain
`window.onerror` listener misses framework-intercepted errors. For failed HTTP
requests, an `HttpClient` interceptor emits a log that carries the active trace
id.
#### Why are my Angular browser metrics or logs missing?
Traces call `provider.register()`, which sets the global tracer, but metrics and
logs have no such sugar. Call `metrics.setGlobalMeterProvider()` and
`logs.setGlobalLoggerProvider()` during bootstrap. Without the global set,
`getMeter()` and `getLogger()` return a No-op implementation and every data
point is silently dropped.
#### Where do I initialize the browser SDK in Angular?
In `main.ts`, before `bootstrapApplication`, so the document-load span and early
interactions are captured. Wire router tracing from the root component
constructor, because the Router is only available through dependency injection.
#### Does Angular HttpClient go through the OpenTelemetry fetch instrumentation?
Yes. In Angular 22, `fetch` is the default `HttpClient` backend and
`withFetch()` is deprecated, so HttpClient requests run through `fetch()` and are
captured by `instrumentation-fetch`, which also injects `traceparent`.
#### How do I send Angular browser telemetry to a collector on another origin?
The browser posts OTLP over HTTP directly from the SPA origin, so the
collector's OTLP HTTP receiver must allow that origin via CORS. Add every origin
the SPA is served from to the receiver's `allowed_origins` list.
#### Can I use Core Web Vitals with OpenTelemetry in Angular?
Yes. The `web-vitals` library reports CLS, INP, LCP, FCP, and TTFB through
callbacks. Record each as a `Histogram` measurement when its callback fires,
tagged with the page path and rating, so page performance aggregates into p75
and p95 alongside your request traces.
### What's Next?
- Add custom metrics (interaction counters, feature-usage rates) alongside the
Web Vitals histograms.
- Instrument additional interactions (`submit`, custom events) and forms.
- Enrich browser error logs with release/version attributes for triage.
- Roll the same three-signal pattern out to other front-end apps for consistent
RUM.
### Complete Example
The full project - Angular 22 SPA, Express + Postgres API, and a pre-configured
collector - is available at
[base-14/examples/nodejs/angular-fullstack-otel](https://github.com/base-14/examples/tree/main/nodejs/angular-fullstack-otel).
```text
angular-fullstack-otel/
├── compose.yaml
├── config/otel-config.yaml # collector: OTLP-in (CORS), traces+metrics+logs
├── backend/ # Express 5 + Postgres, Node OTel SDK
│ ├── src/instrumentation.ts # NodeSDK: traces + metrics + logs
│ ├── src/server.ts # API + pino logging
│ └── schema.sql
└── frontend/ # Angular 22 SPA
├── src/app/telemetry/
│ ├── browser-telemetry.ts # bootstrap: tracer + meter + logger providers
│ ├── router-tracing.ts # NavigationEnd -> span
│ ├── error-handler.ts # ErrorHandler -> best-effort ERROR log
│ ├── error-interceptor.ts # HttpClient failure -> correlated ERROR log
│ └── web-vitals.ts # Core Web Vitals -> metric histograms
├── Dockerfile
└── nginx.conf
```
Run it:
```bash
git clone https://github.com/base-14/examples
cd examples/nodejs/angular-fullstack-otel
docker compose up --build
# open http://localhost:8080/items
```
base14 Scout turns these browser-to-database traces, Web Vitals, and correlated
logs into
[end-to-end application performance monitoring](https://base14.io/scout/apm)
without locking you into a single vendor's agent.
### References
- [OpenTelemetry JavaScript](https://opentelemetry.io/docs/languages/js/)
- [OpenTelemetry browser instrumentation](https://opentelemetry.io/docs/languages/js/getting-started/browser/)
- [auto-instrumentations-web](https://www.npmjs.com/package/@opentelemetry/auto-instrumentations-web)
- [web-vitals](https://github.com/GoogleChrome/web-vitals)
- [Angular zoneless guide](https://angular.dev/guide/zoneless)
- [W3C Trace Context](https://www.w3.org/TR/trace-context/)
### Related Guides
- [React browser instrumentation](./react.md) - full browser RUM powered by the
Scout React SDK.
- [Next.js instrumentation](./nextjs-scout.md) - server and client tracing for a
full-stack React framework.
- [Next.js full-stack instrumentation](./nextjs-fullstack.md) - browser and
server in one trace across all three signals, with browser OTLP routed
through a same-origin API route instead of collector CORS.
- [Express instrumentation](./express.md) - the API tier that receives the
propagated trace context.
- [Node.js instrumentation](./nodejs.md) - the Node SDK behind the backend in
this guide.
---
## Axum OpenTelemetry Instrumentation - Complete APM Setup Guide
## Axum
Implement OpenTelemetry instrumentation for Rust Axum applications to enable
comprehensive application performance monitoring (APM), distributed tracing, and
observability. This guide shows you how to instrument your Axum application to
collect traces, metrics, and logs from HTTP requests, database queries,
background jobs, and custom business logic using the OpenTelemetry Rust SDK.
Axum is a Tokio-based async Rust web framework. [Actix Web](./actix-web.md) is
the other widely used Rust option.
Rust applications built with Axum benefit from the powerful `tracing` ecosystem
combined with OpenTelemetry exporters. With the `tracing-opentelemetry` crate,
you can automatically capture spans from your application, monitor SQLx database
queries, trace distributed transactions across microservices, and identify
performance bottlenecks with minimal runtime overhead.
Whether you're implementing observability for the first time, migrating from
other monitoring solutions, or troubleshooting performance issues in production,
this guide provides production-ready configurations and best practices for Rust
Axum OpenTelemetry instrumentation.
:::tip TL;DR
Add `tower-http` TraceLayer with `tracing-opentelemetry` to export spans via
OTLP. SQLx queries and HTTP requests are traced automatically through tower
middleware. Use `BatchSpanProcessor` for production and export to base14 Scout
via the OpenTelemetry Collector.
:::
> **Note:** This guide provides a practical Axum-focused overview based on the
> official OpenTelemetry documentation. For complete Rust language information,
> please consult the
> [official OpenTelemetry Rust documentation](https://opentelemetry.io/docs/languages/rust/).
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Who This Guide Is For
This documentation is designed for:
- **Rust developers:** implementing observability and distributed tracing for
Axum web applications
- **DevOps engineers:** deploying Rust applications with production monitoring
requirements
- **Engineering teams:** migrating from other APM solutions to OpenTelemetry
- **Developers:** debugging performance issues, slow database queries, or async
runtime problems
- **Platform teams:** standardizing observability across multiple Rust services
### Overview
This comprehensive guide demonstrates how to:
- Install and configure OpenTelemetry SDK for Axum applications
- Set up tracing with `tracing-opentelemetry` for automatic span collection
- Configure OTLP export for traces, metrics, and logs to Scout Collector
- Implement custom instrumentation for business-critical operations
- Monitor SQLx database queries and connection pools
- Deploy instrumented Axum applications to development, staging, and production
environments
- Troubleshoot common instrumentation issues and optimize performance
- Secure sensitive data in telemetry exports
### Prerequisites
Before starting, ensure you have:
- **Rust 1.80 or later** (stable toolchain recommended)
- For best performance and compatibility, Rust 1.92+ is recommended
- Edition 2021 or 2024 required
- **Axum 0.7 or later** web framework
- Axum 0.8.8+ is recommended for optimal OpenTelemetry support
- **Cargo** for dependency management
- **Scout Collector** configured and accessible
- See
[Docker Compose Setup](../../collector-setup/docker-compose-example.md) for
local development
- Production deployments should use a dedicated Scout Collector instance
- Basic understanding of OpenTelemetry concepts (traces, spans, attributes)
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version |
| --------------------- | --------------- | ------------------- |
| Rust | 1.80.0 | 1.92.0+ |
| Axum | 0.7.0 | 0.8.8+ |
| OpenTelemetry | 0.27.0 | 0.32+ |
| tracing-opentelemetry | 0.28.0 | 0.33+ |
| SQLx | 0.7.0 | 0.8.6+ |
### Required Packages
Add the following dependencies to your `Cargo.toml`:
```toml showLineNumbers title="Cargo.toml"
[dependencies]
# Web Framework
axum = { version = "0.8.8", features = ["macros"] }
tower = { version = "0.5.2", features = ["full"] }
tower-http = { version = "0.6.8", features = ["trace", "cors", "timeout", "request-id"] }
# Async Runtime
tokio = { version = "1.49", features = ["full", "tracing"] }
# Database (optional)
sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "macros"] }
# OpenTelemetry
opentelemetry = "0.32"
opentelemetry_sdk = { version = "0.32", features = ["rt-tokio", "logs"] }
opentelemetry-otlp = { version = "0.32", features = ["grpc-tonic", "trace", "logs"] }
opentelemetry-appender-tracing = "0.32"
# Tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-opentelemetry = "0.33"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
```
### Configuration
OpenTelemetry Rust instrumentation can be configured using multiple approaches
depending on your deployment requirements and preferences. Choose the method
that best fits your application architecture.
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
The recommended approach is to create a dedicated telemetry module. This
provides the most flexibility and keeps configuration separate from your
application bootstrap.
```rust showLineNumbers title="src/telemetry/init.rs"
use std::time::Duration;
use opentelemetry::KeyValue;
use opentelemetry::global;
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::{Resource, logs::SdkLoggerProvider, trace::SdkTracerProvider};
use tracing_opentelemetry::OpenTelemetryLayer;
use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt};
pub struct TelemetryGuard {
pub tracer_provider: SdkTracerProvider,
pub logger_provider: SdkLoggerProvider,
}
impl TelemetryGuard {
pub fn shutdown(&self) {
if let Err(e) = self.tracer_provider.shutdown() {
eprintln!("Error shutting down tracer provider: {e}");
}
if let Err(e) = self.logger_provider.shutdown() {
eprintln!("Error shutting down logger provider: {e}");
}
}
}
pub fn init_telemetry(service_name: &str, otlp_endpoint: &str) -> anyhow::Result {
let resource = Resource::builder()
.with_service_name(service_name.to_string())
.with_attribute(KeyValue::new("service.version", "1.0.0"))
.with_attribute(KeyValue::new("service.namespace", "production"))
.with_attribute(KeyValue::new("deployment.environment", "production"))
.with_attribute(KeyValue::new("environment", "production"))
.build();
// Configure trace exporter
let trace_exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(otlp_endpoint)
.with_timeout(Duration::from_secs(10))
.build()?;
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(trace_exporter)
.with_resource(resource.clone())
.build();
global::set_tracer_provider(tracer_provider.clone());
// Configure log exporter
let log_exporter = opentelemetry_otlp::LogExporter::builder()
.with_tonic()
.with_endpoint(otlp_endpoint)
.with_timeout(Duration::from_secs(10))
.build()?;
let logger_provider = SdkLoggerProvider::builder()
.with_batch_exporter(log_exporter)
.with_resource(resource)
.build();
// Bridge tracing logs to OpenTelemetry
let otel_log_layer = OpenTelemetryTracingBridge::new(&logger_provider);
// Create OpenTelemetry tracing layer
let tracer = global::tracer(service_name.to_string());
let telemetry_layer = OpenTelemetryLayer::new(tracer);
// Configure environment filter
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,tower_http=debug"));
// Initialize subscriber
tracing_subscriber::registry()
.with(env_filter)
.with(telemetry_layer)
.with(otel_log_layer)
.with(tracing_subscriber::fmt::layer())
.init();
tracing::info!(
service = %service_name,
endpoint = %otlp_endpoint,
"Telemetry initialized with OTLP trace and log export"
);
Ok(TelemetryGuard {
tracer_provider,
logger_provider,
})
}
```
```mdx-code-block
```
For containerized deployments or environments where configuration is managed
externally, you can rely on environment variables:
```rust showLineNumbers title="src/telemetry/init.rs"
use std::env;
pub fn init_telemetry_from_env() -> anyhow::Result {
let service_name = env::var("OTEL_SERVICE_NAME")
.unwrap_or_else(|_| "rust-axum-app".to_string());
let otlp_endpoint = env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:4317".to_string());
init_telemetry(&service_name, &otlp_endpoint)
}
```
With this configuration, use environment variables to control behavior:
```bash showLineNumbers
export OTEL_SERVICE_NAME=rust-axum-app
export OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4317
export RUST_LOG=info,sqlx=warn,tower_http=debug
```
```mdx-code-block
```
For applications using a configuration struct pattern:
```rust showLineNumbers title="src/config.rs"
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_environment")]
pub environment: String,
#[serde(default = "default_service_name")]
pub otel_service_name: String,
#[serde(default = "default_otel_endpoint")]
pub otel_exporter_endpoint: String,
pub database_url: String,
}
fn default_port() -> u16 { 8080 }
fn default_environment() -> String { "development".to_string() }
fn default_service_name() -> String { "rust-axum-app".to_string() }
fn default_otel_endpoint() -> String { "http://localhost:4317".to_string() }
impl Config {
pub fn from_env() -> Self {
dotenvy::dotenv().ok();
envy::from_env().expect("Failed to load config from environment")
}
pub fn is_production(&self) -> bool {
self.environment == "production"
}
}
```
```mdx-code-block
```
#### Configuring Tower HTTP Tracing Layer
Add the Tower HTTP tracing layer to your Axum router for automatic HTTP request
instrumentation:
```rust showLineNumbers title="src/main.rs"
use std::time::Duration;
use axum::http::{Request, Response};
use tower_http::trace::{MakeSpan, OnResponse, TraceLayer};
use tracing::Span;
#[derive(Clone)]
struct HttpMakeSpan;
impl MakeSpan for HttpMakeSpan {
fn make_span(&mut self, request: &Request) -> Span {
let method = request.method().as_str();
let path = request.uri().path();
tracing::info_span!(
"HTTP request",
otel.name = %format!("{} {}", method, path),
http.method = %method,
http.route = %path,
http.target = %request.uri(),
http.scheme = "http",
http.response.status_code = tracing::field::Empty,
otel.status_code = tracing::field::Empty,
)
}
}
#[derive(Clone)]
struct HttpOnResponse;
impl OnResponse for HttpOnResponse {
fn on_response(self, response: &Response, latency: Duration, span: &Span) {
let status = response.status().as_u16();
span.record("http.response.status_code", status as i64);
if status >= 500 {
span.record("otel.status_code", "ERROR");
} else {
span.record("otel.status_code", "OK");
}
tracing::info!(
http.response.status_code = status,
latency_ms = latency.as_secs_f64() * 1000.0,
"finished processing request"
);
}
}
```
#### Scout Collector Integration
When using Scout Collector, configure your Axum application to send telemetry
data to the Scout Collector endpoint:
```rust showLineNumbers title="src/telemetry/init.rs"
pub fn init_telemetry_with_scout(
service_name: &str,
scout_endpoint: &str,
scout_api_key: Option<&str>,
) -> anyhow::Result {
let mut headers = tonic::metadata::MetadataMap::new();
if let Some(api_key) = scout_api_key {
headers.insert("x-scout-api-key", api_key.parse()?);
}
let trace_exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(scout_endpoint)
.with_metadata(headers.clone())
.with_timeout(Duration::from_secs(10))
.build()?;
// ... rest of configuration
}
```
> **Scout Dashboard Integration**: After configuration, your traces will appear
> in the Scout Dashboard. Navigate to the Traces section to view request flows,
> identify performance bottlenecks, and analyze distributed transactions across
> your Rust services.
### Production Configuration
Production deployments require additional configuration for optimal performance,
reliability, and resource utilization. This section covers production-specific
settings and best practices.
#### Batch Span Processor (Default for Production)
The `BatchSpanProcessor` is used by default when calling `with_batch_exporter`:
```rust showLineNumbers title="src/telemetry/init.rs"
use opentelemetry_sdk::trace::{BatchConfigBuilder, SdkTracerProvider};
pub fn init_production_telemetry(
service_name: &str,
otlp_endpoint: &str,
) -> anyhow::Result {
let resource = Resource::builder()
.with_service_name(service_name.to_string())
.with_attribute(KeyValue::new("service.version", env!("CARGO_PKG_VERSION")))
.with_attribute(KeyValue::new("deployment.environment", "production"))
.with_attribute(KeyValue::new("environment", "production"))
.build();
let trace_exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(otlp_endpoint)
.with_timeout(Duration::from_secs(30))
.build()?;
// Configure batch processor for production
let batch_config = BatchConfigBuilder::default()
.with_max_queue_size(2048)
.with_scheduled_delay(Duration::from_secs(5))
.with_max_export_batch_size(512)
.build();
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(trace_exporter)
.with_resource(resource)
.build();
global::set_tracer_provider(tracer_provider.clone());
// ... rest of configuration
}
```
**Benefits of BatchSpanProcessor:**
- Reduces network requests by batching span exports
- Lower CPU overhead compared to immediate export
- Prevents network saturation during traffic spikes
- Configurable batching for optimal throughput
#### Resource Attributes
Add rich context to all telemetry data with resource attributes:
```rust showLineNumbers title="src/telemetry/init.rs"
use std::net::IpAddr;
fn build_resource(service_name: &str, environment: &str) -> Resource {
let hostname = hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|_| "unknown".to_string());
Resource::builder()
.with_service_name(service_name.to_string())
.with_attribute(KeyValue::new("service.version", env!("CARGO_PKG_VERSION")))
.with_attribute(KeyValue::new("service.namespace", "production"))
.with_attribute(KeyValue::new("deployment.environment", environment.to_string()))
.with_attribute(KeyValue::new("environment", environment.to_string()))
.with_attribute(KeyValue::new("host.name", hostname))
.with_attribute(KeyValue::new(
"process.runtime.name",
"rustc".to_string()
))
.with_attribute(KeyValue::new(
"process.runtime.version",
env!("CARGO_PKG_RUST_VERSION")
))
.build()
}
```
#### Environment-Based Configuration
Use environment variables to manage configuration across deployments:
```rust showLineNumbers title="src/telemetry/init.rs"
pub fn init_telemetry(config: &Config) -> anyhow::Result {
let resource = build_resource(&config.otel_service_name, &config.environment);
let trace_exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(&config.otel_exporter_endpoint)
.with_timeout(Duration::from_secs(10))
.build()?;
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(trace_exporter)
.with_resource(resource.clone())
.build();
global::set_tracer_provider(tracer_provider.clone());
// Configure format layer based on environment
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,tower_http=debug"));
let fmt_layer = if config.is_production() {
tracing_subscriber::fmt::layer().json().boxed()
} else {
tracing_subscriber::fmt::layer().pretty().boxed()
};
// ... rest of configuration
}
```
#### Production Environment Variables
Create a production environment configuration:
```bash showLineNumbers title=".env.production"
# Service Configuration
OTEL_SERVICE_NAME=rust-axum-app
RUST_LOG=info,sqlx=warn,tower_http=info
# Scout Collector Endpoint
OTEL_EXPORTER_OTLP_ENDPOINT=https://scout-collector.example.com:4317
SCOUT_API_KEY=your-scout-api-key
# Application Settings
PORT=8080
ENVIRONMENT=production
DATABASE_URL=postgres://user:pass@db:5432/production
```
#### Docker Production Configuration
For containerized Axum applications, configure OpenTelemetry in your Docker
setup:
```dockerfile showLineNumbers title="Dockerfile"
# Build stage
FROM rust:1.80-alpine AS builder
WORKDIR /app
RUN apk add --no-cache musl-dev openssl-dev pkgconfig
# Copy dependency files first for caching
COPY Cargo.toml Cargo.lock ./
# Create dummy source to build dependencies
RUN mkdir src && \
echo "fn main() {}" > src/main.rs
# Build dependencies only
RUN cargo build --release 2>/dev/null || true
# Remove dummy source and copy actual source
RUN rm -rf src
COPY src ./src
# Build the actual application
RUN touch src/main.rs && cargo build --release
# Runtime stage
FROM alpine:3.21
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata && \
adduser -D -g '' -u 1001 appuser
COPY --from=builder /app/target/release/api .
USER appuser
ENV OTEL_SERVICE_NAME=rust-axum-app
ENV OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4317
EXPOSE 8080
CMD ["./api"]
```
```yaml showLineNumbers title="docker-compose.yml"
services:
rust-app:
build: .
environment:
OTEL_SERVICE_NAME: rust-axum-app
OTEL_EXPORTER_OTLP_ENDPOINT: http://scout-collector:4317
DATABASE_URL: postgres://user:pass@postgres:5432/production
RUST_LOG: info,sqlx=warn
depends_on:
- postgres
- scout-collector
ports:
- "8080:8080"
scout-collector:
image: base14/scout-collector:latest
ports:
- "4317:4317"
- "4318:4318"
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: password
```
### Metrics
In addition to traces, OpenTelemetry can collect metrics from your Axum
application to monitor resource utilization, request rates, error counts, and
custom business metrics.
#### Defining Custom Metrics
Create a metrics module with static metric definitions:
```rust showLineNumbers title="src/telemetry/metrics.rs"
use std::sync::LazyLock;
use opentelemetry::{
global,
metrics::{Counter, Histogram, Meter},
};
pub static METER: LazyLock = LazyLock::new(|| global::meter("rust-axum-app"));
pub static HTTP_REQUESTS_TOTAL: LazyLock> = LazyLock::new(|| {
METER
.u64_counter("http.requests.total")
.with_description("Total number of HTTP requests")
.with_unit("{request}")
.build()
});
pub static HTTP_REQUEST_DURATION: LazyLock> = LazyLock::new(|| {
METER
.f64_histogram("http.request.duration")
.with_description("HTTP request duration in milliseconds")
.with_unit("ms")
.with_boundaries(vec![
1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0,
])
.build()
});
pub static ARTICLES_CREATED: LazyLock> = LazyLock::new(|| {
METER
.u64_counter("articles.created")
.with_description("Total articles created")
.build()
});
pub static USERS_REGISTERED: LazyLock> = LazyLock::new(|| {
METER
.u64_counter("users.registered")
.with_description("Total users registered")
.build()
});
```
#### Recording HTTP Metrics
Record metrics in your Tower HTTP response handler:
```rust showLineNumbers title="src/main.rs"
use crate::telemetry::{HTTP_REQUESTS_TOTAL, HTTP_REQUEST_DURATION};
use opentelemetry::KeyValue;
impl OnResponse for HttpOnResponse {
fn on_response(self, response: &Response, latency: Duration, span: &Span) {
let status = response.status().as_u16();
let latency_ms = latency.as_secs_f64() * 1000.0;
let status_class = format!("{}xx", status / 100);
// Record metrics
HTTP_REQUESTS_TOTAL.add(
1,
&[
KeyValue::new("http.status_code", status.to_string()),
KeyValue::new("http.status_class", status_class.clone()),
],
);
HTTP_REQUEST_DURATION.record(
latency_ms,
&[
KeyValue::new("http.status_code", status.to_string()),
KeyValue::new("http.status_class", status_class),
],
);
span.record("http.response.status_code", status as i64);
if status >= 500 {
span.record("otel.status_code", "ERROR");
} else {
span.record("otel.status_code", "OK");
}
}
}
```
#### Custom Business Metrics
Track business-specific events and KPIs:
```rust showLineNumbers title="src/services/article.rs"
use crate::telemetry::{ARTICLES_CREATED, ARTICLES_DELETED};
impl ArticleService {
pub async fn create(&self, author_id: i32, input: CreateArticleInput) -> AppResult {
// ... create article logic
// Record business metric
ARTICLES_CREATED.add(1, &[]);
tracing::info!(article_id = article.id, "Article created");
Ok(article)
}
pub async fn delete(&self, slug: &str, user_id: i32) -> AppResult<()> {
// ... delete article logic
ARTICLES_DELETED.add(1, &[]);
Ok(())
}
}
```
### SQLx Database Monitoring
OpenTelemetry integrates with SQLx through the tracing ecosystem to provide
comprehensive database query monitoring.
#### Automatic Query Tracing
SQLx automatically emits tracing spans when queries are executed. Ensure your
environment filter includes SQLx:
```rust showLineNumbers
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,tower_http=debug"));
```
#### Configuring SQLx Connection Pool
Configure your database pool with proper settings:
```rust showLineNumbers title="src/database/pool.rs"
use sqlx::postgres::{PgPool, PgPoolOptions};
pub async fn create_pool(database_url: &str) -> Result {
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(5)
.acquire_timeout(Duration::from_secs(30))
.idle_timeout(Duration::from_secs(600))
.max_lifetime(Duration::from_secs(1800))
.connect(database_url)
.await?;
tracing::info!("Database connection pool created");
Ok(pool)
}
```
#### Instrumenting Repository Methods
Use the `#[instrument]` macro for automatic span creation:
```rust showLineNumbers title="src/repository/article.rs"
use tracing::instrument;
#[derive(Clone)]
pub struct ArticleRepository {
pool: PgPool,
}
impl ArticleRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
#[instrument(name = "db.article.create", skip(self))]
pub async fn create(
&self,
slug: &str,
title: &str,
description: &str,
body: &str,
author_id: i32,
) -> Result {
sqlx::query_as!(
Article,
r#"
INSERT INTO articles (slug, title, description, body, author_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, slug, title, description, body, author_id,
favorites_count, created_at, updated_at
"#,
slug,
title,
description,
body,
author_id
)
.fetch_one(&self.pool)
.await
}
#[instrument(name = "db.article.find_by_slug", skip(self))]
pub async fn find_by_slug(&self, slug: &str) -> Result, sqlx::Error> {
sqlx::query_as!(
Article,
"SELECT * FROM articles WHERE slug = $1",
slug
)
.fetch_optional(&self.pool)
.await
}
}
```
**SQLx span attributes include:**
- `db.system` - Database type (postgresql)
- `db.name` - Database name
- `db.statement` - SQL query
- `db.operation` - Operation type (SELECT, INSERT, UPDATE, DELETE)
### Custom Manual Instrumentation
While automatic instrumentation covers most Axum components, you can add custom
instrumentation for business logic, external API calls, or performance-critical
code paths.
#### Creating Custom Spans with the Instrument Macro
Use the `#[instrument]` macro from the `tracing` crate:
```rust showLineNumbers title="src/services/article.rs"
use tracing::instrument;
#[derive(Clone)]
pub struct ArticleService {
article_repo: ArticleRepository,
favorite_repo: FavoriteRepository,
job_queue: JobQueue,
}
impl ArticleService {
#[instrument(name = "article.create", skip(self, input), fields(author_id))]
pub async fn create(
&self,
author_id: i32,
input: CreateArticleInput,
) -> AppResult {
let slug = self.generate_slug(&input.title);
let article = self
.article_repo
.create(&slug, &input.title, &input.description, &input.body, author_id)
.await?;
// Enqueue background job
if let Err(e) = self.job_queue.enqueue_notification(article.id, &article.title).await {
tracing::warn!(article_id = article.id, error = %e, "Failed to enqueue notification");
}
ARTICLES_CREATED.add(1, &[]);
tracing::info!(article_id = article.id, slug = %slug, "Article created");
Ok(ArticleResponse::from(article))
}
#[instrument(name = "article.delete", skip(self))]
pub async fn delete(&self, slug: &str, user_id: i32) -> AppResult<()> {
let article = self
.article_repo
.find_by_slug(slug)
.await?
.ok_or(AppError::NotFound("Article not found".to_string()))?;
if article.author_id != user_id {
return Err(AppError::Forbidden);
}
self.article_repo.delete(article.id).await?;
ARTICLES_DELETED.add(1, &[]);
tracing::info!(article_id = article.id, "Article deleted");
Ok(())
}
}
```
#### Adding Attributes to Current Spans
Enrich existing spans with additional context:
```rust showLineNumbers title="src/middleware/auth.rs"
use tracing::Span;
pub async fn auth_middleware(
State(state): State,
mut request: Request,
next: Next,
) -> Result {
let token = extract_token(&request)?;
let claims = state.auth_service.validate_token(&token)?;
// Add user context to current span
Span::current().record("user.id", claims.user_id);
Span::current().record("user.role", &claims.role);
request.extensions_mut().insert(claims);
Ok(next.run(request).await)
}
```
#### Exception Handling and Error Tracking
Capture errors in custom spans:
```rust showLineNumbers title="src/services/external_api.rs"
use tracing::{instrument, Span};
pub struct ExternalApiClient {
client: reqwest::Client,
base_url: String,
}
impl ExternalApiClient {
#[instrument(name = "external_api.fetch", skip(self))]
pub async fn fetch_data(&self, endpoint: &str) -> Result {
let url = format!("{}/{}", self.base_url, endpoint);
let response = self.client
.get(&url)
.send()
.await
.map_err(|e| {
Span::current().record("otel.status_code", "ERROR");
tracing::error!(error = %e, "External API request failed");
AppError::ExternalService(e.to_string())
})?;
let status = response.status();
Span::current().record("http.response.status_code", status.as_u16() as i64);
if !status.is_success() {
Span::current().record("otel.status_code", "ERROR");
return Err(AppError::ExternalService(format!("HTTP {}", status)));
}
response.json().await.map_err(|e| {
tracing::error!(error = %e, "Failed to parse response");
AppError::ExternalService(e.to_string())
})
}
}
```
#### Using Semantic Conventions
Follow OpenTelemetry semantic conventions for consistent attribute naming:
```rust showLineNumbers
// HTTP semantic conventions
tracing::info_span!(
"http.request",
http.method = %method,
http.url = %url,
http.status_code = tracing::field::Empty,
http.request.header.content_type = "application/json"
);
// Database semantic conventions
tracing::info_span!(
"db.query",
db.system = "postgresql",
db.name = "production",
db.operation = "SELECT",
db.statement = "SELECT * FROM users WHERE id = $1"
);
// Messaging semantic conventions
tracing::info_span!(
"messaging.process",
messaging.system = "redis",
messaging.destination = "jobs_queue",
messaging.operation = "process"
);
```
### Running Your Instrumented Application
```mdx-code-block
```
For local development, use console output to verify instrumentation:
```rust showLineNumbers title="src/telemetry/init.rs"
pub fn init_development_telemetry() -> anyhow::Result<()> {
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("debug,sqlx=info,tower_http=debug"));
tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer().pretty())
.init();
Ok(())
}
```
Start your Axum server:
```bash
RUST_LOG=debug cargo run
```
You'll see span output in the console for each request:
```text
2024-01-15T10:30:45.123Z DEBUG HTTP request{otel.name="GET /api/articles" http.method="GET"}
at src/main.rs:52
2024-01-15T10:30:45.125Z DEBUG db.article.list
at src/repository/article.rs:45
2024-01-15T10:30:45.130Z INFO finished processing request http.response.status_code=200 latency_ms=7.2
```
```mdx-code-block
```
For production deployments, ensure the Scout Collector endpoint is properly
configured:
```bash showLineNumbers
# Set environment variables
export OTEL_SERVICE_NAME=rust-axum-app-production
export OTEL_EXPORTER_OTLP_ENDPOINT=https://scout-collector.example.com:4317
export RUST_LOG=info,sqlx=warn
export ENVIRONMENT=production
# Run the application
./target/release/api
```
```mdx-code-block
```
Run your instrumented Axum application in Docker:
```bash showLineNumbers
# Build the image
docker build -t rust-axum-app:latest .
# Run with Scout Collector
docker run -d \
--name rust-axum-app \
-e OTEL_SERVICE_NAME=rust-axum-app \
-e OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4317 \
-e DATABASE_URL=postgres://user:pass@db:5432/production \
-p 8080:8080 \
rust-axum-app:latest
```
Or use Docker Compose (see [Production Configuration](#production-configuration)
section for complete example).
```mdx-code-block
```
### Troubleshooting
#### Verifying OpenTelemetry Installation
Test your OpenTelemetry configuration by creating a test span:
```rust showLineNumbers title="src/main.rs"
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = Config::from_env();
let telemetry_guard = init_telemetry(&config)?;
// Test span
tracing::info_span!("startup_check").in_scope(|| {
tracing::info!("OpenTelemetry is working!");
});
// ... rest of application startup
// Ensure clean shutdown
telemetry_guard.shutdown();
Ok(())
}
```
#### Health Check Endpoint
Create a health check endpoint to verify telemetry export:
```rust showLineNumbers title="src/handlers/health.rs"
use axum::{Json, extract::State};
use serde::Serialize;
#[derive(Serialize)]
pub struct HealthResponse {
status: String,
service: String,
version: String,
}
#[tracing::instrument(name = "health.check", skip(state))]
pub async fn health_check(State(state): State) -> Json {
// Verify database connectivity
let db_status = sqlx::query("SELECT 1")
.fetch_one(&state.pool)
.await
.is_ok();
tracing::info!(db_healthy = db_status, "Health check performed");
Json(HealthResponse {
status: if db_status { "ok" } else { "degraded" }.to_string(),
service: std::env::var("OTEL_SERVICE_NAME").unwrap_or_default(),
version: env!("CARGO_PKG_VERSION").to_string(),
})
}
```
#### Debug Mode
Enable debug logging to troubleshoot instrumentation issues:
```bash
export RUST_LOG=debug,opentelemetry=debug,tracing_opentelemetry=debug
cargo run
```
#### Common Issues
##### Issue: No traces appearing in Scout Dashboard
**Solutions:**
1. Verify Scout Collector endpoint is reachable:
```bash
curl -v http://scout-collector:4317/v1/traces
```
2. Check environment variables:
```bash
echo $OTEL_EXPORTER_OTLP_ENDPOINT
echo $OTEL_SERVICE_NAME
```
3. Enable debug logging and check for export errors
4. Verify network connectivity between your app and Scout Collector
##### Issue: Missing database query spans
**Solutions:**
1. Ensure SQLx logging level is set to at least `warn`:
```rust
EnvFilter::new("info,sqlx=warn")
```
2. Verify you're using async SQLx methods that emit tracing spans
3. Check that `tracing-opentelemetry` layer is properly configured
##### Issue: High memory usage
**Solutions:**
1. Reduce `max_queue_size` in batch processor configuration
2. Ensure spans are being exported successfully
3. Check for span attribute size limits
##### Issue: Performance degradation
**Solutions:**
1. Use batch processor instead of simple processor
2. Reduce logging verbosity in production
3. Skip health check endpoints from tracing
### Security Considerations
#### Protecting Sensitive Data
Avoid adding sensitive information to span attributes:
```rust showLineNumbers
// Bad - exposes sensitive data
tracing::info!(
user.password = %password, // Never include passwords!
credit_card = %card_number, // Never include payment data!
user.ssn = %social_security // Never include PII!
);
// Good - uses safe identifiers
tracing::info!(
user.id = user_id,
user.role = %role,
payment.provider = "stripe",
payment.status = "completed"
);
```
#### Sanitizing SQL Statements
Configure SQLx to avoid logging sensitive query parameters:
```rust showLineNumbers
// Use parameterized queries - values are not logged
let user = sqlx::query_as!(
User,
"SELECT * FROM users WHERE email = $1 AND password_hash = $2",
email,
password_hash
)
.fetch_optional(&pool)
.await?;
```
#### Filtering Sensitive HTTP Headers
Skip sensitive headers in your tracing configuration:
```rust showLineNumbers
impl MakeSpan for HttpMakeSpan {
fn make_span(&mut self, request: &Request) -> Span {
// Don't include Authorization header in spans
tracing::info_span!(
"HTTP request",
http.method = %request.method(),
http.route = %request.uri().path(),
// Omit: http.request.header.authorization
)
}
}
```
#### Compliance Considerations
For applications handling regulated data (GDPR, HIPAA, PCI-DSS):
- Never include personally identifiable information (PII) in spans
- Use hashed or anonymized user identifiers
- Implement data retention policies in Scout Dashboard
- Use parameterized queries to avoid logging sensitive values
- Audit span attributes regularly for sensitive data leaks
### Performance Considerations
#### Expected Performance Impact
OpenTelemetry instrumentation adds minimal overhead to Rust Axum applications:
- **Average latency increase**: < 1ms per request
- **CPU overhead**: < 1% in production with batch processor
- **Memory overhead**: ~20-50MB depending on queue size and traffic
**Impact varies based on:**
- Number of spans generated per request
- Span processor type (Batch vs Simple)
- Application request volume
- Complexity of traced operations
#### Optimization Best Practices
##### 1. Use Batch Processor in Production
```rust showLineNumbers
// Good - batches exports, low overhead
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(trace_exporter)
.build();
// Avoid in production - exports every span immediately
let tracer_provider = SdkTracerProvider::builder()
.with_simple_exporter(trace_exporter)
.build();
```
##### 2. Skip Non-Critical Endpoints
```rust showLineNumbers
impl MakeSpan for HttpMakeSpan {
fn make_span(&mut self, request: &Request) -> Span {
let path = request.uri().path();
// Skip tracing for health checks
if path == "/health" || path == "/metrics" {
return tracing::Span::none();
}
// ... normal span creation
}
}
```
##### 3. Use Appropriate Log Levels
```rust showLineNumbers
// Production: minimal logging
let env_filter = EnvFilter::new("info,sqlx=warn,tower_http=info");
// Development: verbose logging
let env_filter = EnvFilter::new("debug,sqlx=debug,tower_http=debug");
```
##### 4. Limit Attribute Values
```rust showLineNumbers
// Truncate long values
let truncated_body = if body.len() > 1000 {
format!("{}...", &body[..1000])
} else {
body.to_string()
};
tracing::info!(request.body = %truncated_body);
```
### Frequently Asked Questions
#### Does OpenTelemetry impact Rust application performance?
OpenTelemetry adds approximately < 1ms of latency per request in typical Axum
applications. Rust's zero-cost abstractions and the efficient `tracing` crate
minimize overhead. With proper configuration (batch processor), the performance
impact is negligible for most production workloads.
#### Which Rust versions are supported?
OpenTelemetry Rust supports Rust 1.80+ with edition 2021 or 2024. Rust 1.92+ is
recommended for optimal compatibility and performance. See the
[Prerequisites](#prerequisites) section for detailed version compatibility.
#### Can I use OpenTelemetry with async Rust and Tokio?
Yes! OpenTelemetry Rust is designed for async applications. The `tracing` crate
handles async context propagation automatically, and `tracing-opentelemetry`
bridges tracing spans to OpenTelemetry. Use `opentelemetry_sdk` with the
`rt-tokio` feature for Tokio runtime support.
#### How do I trace async tasks spawned with tokio::spawn?
Use `tracing::Instrument` to propagate context to spawned tasks:
```rust
use tracing::Instrument;
let span = tracing::info_span!("background_task");
tokio::spawn(async move {
// Work here is traced under the span
}.instrument(span));
```
#### Can I use OpenTelemetry alongside other observability tools?
Yes, OpenTelemetry can run alongside tools like Prometheus or Jaeger during
migration periods. The `tracing` ecosystem allows multiple subscribers.
However, running multiple exporters simultaneously will increase overhead.
#### How do I handle multi-tenant applications?
Record the tenant as a field on the request span, then filter by it in
Scout:
```rust
tracing::info_span!(
"request",
tenant.id = %tenant_id,
tenant.name = %tenant_name
).in_scope(|| {
// Request handling
});
```
#### What's the difference between tracing and OpenTelemetry?
`tracing` is Rust's native instrumentation library for structured logging and
spans. `tracing-opentelemetry` bridges tracing spans to OpenTelemetry format
for export to APM backends. Use `tracing` for instrumentation and OpenTelemetry
for export.
#### How do I monitor SQLx connection pool health?
SQLx emits tracing spans for connection pool operations. Monitor these spans
for connection acquisition times and pool exhaustion:
```rust
// This query automatically emits tracing spans
let result = sqlx::query("SELECT 1").fetch_one(&pool).await?;
```
#### Can I customize which operations are instrumented?
Yes! Use the `#[instrument]` macro selectively and configure the `EnvFilter`
to control which modules emit spans. You can also use `Span::none()` to skip
tracing entirely for specific operations.
### What's Next?
Now that your Axum application is instrumented with OpenTelemetry, explore these
resources to maximize your observability:
#### Advanced Topics
- **[PostgreSQL Monitoring Best Practices](../../component/postgres.md)** -
Optimize database observability with connection pooling metrics and query
performance analysis
#### Scout Platform Features
- **[Creating Alerts](../../../guides/creating-alerts-with-logx.md)** - Set up
intelligent alerts for error rates, latency thresholds, and custom metrics
- **[Dashboard Creation](../../../guides/create-your-first-dashboard.md)** -
Build custom dashboards combining traces, metrics, and business KPIs
#### Deployment and Operations
- **[Docker Compose Setup](../../collector-setup/docker-compose-example.md)** -
Set up Scout Collector for local development and testing
### Complete Example
Here's a complete working example of an Axum application with OpenTelemetry
instrumentation:
#### Cargo.toml
```toml showLineNumbers title="Cargo.toml"
[package]
name = "rust-axum-otel"
version = "1.0.0"
edition = "2024"
rust-version = "1.92"
[dependencies]
axum = { version = "0.8.8", features = ["macros"] }
tower = { version = "0.5.2", features = ["full"] }
tower-http = { version = "0.6.8", features = ["trace", "cors", "timeout"] }
tokio = { version = "1.49", features = ["full", "tracing"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres"] }
opentelemetry = "0.32"
opentelemetry_sdk = { version = "0.32", features = ["rt-tokio", "logs"] }
opentelemetry-otlp = { version = "0.32", features = ["grpc-tonic", "trace", "logs"] }
opentelemetry-appender-tracing = "0.32"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-opentelemetry = "0.33"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
```
#### Telemetry Module
```rust showLineNumbers title="src/telemetry.rs"
use std::time::Duration;
use opentelemetry::KeyValue;
use opentelemetry::global;
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::{Resource, logs::SdkLoggerProvider, trace::SdkTracerProvider};
use tracing_opentelemetry::OpenTelemetryLayer;
use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt};
pub struct TelemetryGuard {
tracer_provider: SdkTracerProvider,
logger_provider: SdkLoggerProvider,
}
impl TelemetryGuard {
pub fn shutdown(&self) {
let _ = self.tracer_provider.shutdown();
let _ = self.logger_provider.shutdown();
}
}
pub fn init(service_name: &str, endpoint: &str) -> anyhow::Result {
let resource = Resource::builder()
.with_service_name(service_name.to_string())
.with_attribute(KeyValue::new("service.version", "1.0.0"))
.build();
let trace_exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(endpoint)
.with_timeout(Duration::from_secs(10))
.build()?;
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(trace_exporter)
.with_resource(resource.clone())
.build();
global::set_tracer_provider(tracer_provider.clone());
let log_exporter = opentelemetry_otlp::LogExporter::builder()
.with_tonic()
.with_endpoint(endpoint)
.build()?;
let logger_provider = SdkLoggerProvider::builder()
.with_batch_exporter(log_exporter)
.with_resource(resource)
.build();
let tracer = global::tracer(service_name.to_string());
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(OpenTelemetryLayer::new(tracer))
.with(OpenTelemetryTracingBridge::new(&logger_provider))
.with(tracing_subscriber::fmt::layer())
.init();
Ok(TelemetryGuard { tracer_provider, logger_provider })
}
```
#### Main Application
```rust showLineNumbers title="src/main.rs"
use std::net::SocketAddr;
use axum::{Router, routing::get, Json};
use serde::Serialize;
use tokio::net::TcpListener;
mod telemetry;
#[derive(Serialize)]
struct HealthResponse {
status: String,
}
#[tracing::instrument(name = "health.check")]
async fn health_check() -> Json {
tracing::info!("Health check requested");
Json(HealthResponse { status: "ok".to_string() })
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let service_name = std::env::var("OTEL_SERVICE_NAME")
.unwrap_or_else(|_| "rust-axum-app".to_string());
let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:4317".to_string());
let guard = telemetry::init(&service_name, &endpoint)?;
tracing::info!(service = %service_name, "Starting server");
let app = Router::new().route("/health", get(health_check));
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
let listener = TcpListener::bind(addr).await?;
tracing::info!(%addr, "Server listening");
axum::serve(listener, app).await?;
guard.shutdown();
Ok(())
}
```
#### Environment Variables
```bash showLineNumbers title=".env"
OTEL_SERVICE_NAME=rust-axum-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4317
RUST_LOG=info,sqlx=warn
```
This complete example is available in our
[GitHub examples repository](https://github.com/base-14/examples/tree/main/rust/axum-postgres).
### References
- [Official OpenTelemetry Rust Documentation](https://opentelemetry.io/docs/languages/rust/)
- [tracing-opentelemetry Crate](https://docs.rs/tracing-opentelemetry)
- [Axum Documentation](https://docs.rs/axum)
### Related Guides
- [Rust LLM Observability][rust-llm] - GenAI semantic conventions,
token/cost tracking, multi-provider LLM with fallback for Rust AI apps
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) - Set
up collector for local development
- [Rust Custom Instrumentation](../custom-instrumentation/rust.md) - Manual
spans and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
[rust-llm]: ../../../../guides/ai-observability/rust-llm-observability
---
## BullMQ OpenTelemetry Instrumentation - Job & Queue Tracing
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
## BullMQ
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Overview
Implement OpenTelemetry instrumentation for [BullMQ](https://bullmq.io) to get
distributed tracing and metrics across your Redis-backed background jobs. This
guide shows you how to connect the enqueue side and the worker side into a
single trace, capture per-job spans with timing and error status, and export
queue-depth metrics so you can alert on backlog and failure rate.
BullMQ is a Redis-backed queue used to move slow or unreliable work - email,
notifications, webhooks, report generation, image processing - out of the
request path. Because the producer and the worker run in different processes and
communicate through Redis, a job that fails or runs slowly is hard to debug
without a trace that spans both sides. OpenTelemetry gives you that end-to-end
view.
Unlike database drivers or HTTP frameworks, BullMQ has **no dedicated
auto-instrumentation package** in the OpenTelemetry contrib bundle. Observability
comes from two pieces working together: the
[`instrumentation-ioredis`](https://www.npmjs.com/package/@opentelemetry/instrumentation-ioredis)
package, which automatically traces the Redis commands BullMQ runs, and a small
amount of manual instrumentation that creates a job span and propagates trace
context through the job payload. This guide covers both.
:::tip TL;DR
BullMQ jobs are not auto-traced end-to-end. Add
`@opentelemetry/instrumentation-ioredis` to get the underlying Redis command
spans, then on the producer inject the active context into the job data with
`propagation.inject`, and in the worker extract it with `propagation.extract`
and wrap the work in `tracer.startActiveSpan` inside `context.with`. Export
queue depth with OpenTelemetry observable gauges reading BullMQ's
`getWaitingCount()` / `getActiveCount()` family.
:::
### Who This Guide Is For
This documentation is designed for:
- **Node.js backend engineers** running BullMQ workers in production who need to
see why a job is slow or failing.
- **Teams migrating from New Relic or Datadog** that had queue dashboards and
want equivalent visibility on an OpenTelemetry-native stack.
- **NestJS developers** using `@nestjs/bullmq` who want producer-to-worker
traces across the dependency injection layer.
- **Platform / SRE teams** standardizing background-job observability and alerts
(backlog, failure rate, processing latency) across services.
- **Developers debugging distributed flows** where an HTTP request enqueues a
job and the real work happens asynchronously in another process.
### Prerequisites
Before starting, ensure you have:
- **Node.js 18.x or later** (20.x LTS recommended for production)
- **BullMQ 5.x** (`bullmq`)
- **Redis 6.2 or later** (BullMQ requires Redis; 7.x recommended)
- **Scout Collector** configured and reachable
- See [Docker Compose Setup](../../collector-setup/docker-compose-example.md)
for local development
- See [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md)
for production
- Basic understanding of OpenTelemetry concepts (traces, spans, context)
- Familiarity with the BullMQ `Queue` and `Worker` APIs
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version |
| ------------------------------------------ | --------------- | ------------------- |
| Node.js | 18.0.0 | 20.x LTS |
| BullMQ | 5.0.0 | 5.x |
| Redis | 6.2.0 | 7.x |
| @opentelemetry/sdk-node | 0.45.0 | 0.54+ |
| @opentelemetry/instrumentation-ioredis | 0.40.0 | 0.45+ |
| @opentelemetry/api | 1.7.0 | 1.9+ |
#### What Gets Instrumented
| Signal source | How | Automatic? |
| ------------------------------ | ------------------------------------- | ---------- |
| Redis commands (queue ops) | `instrumentation-ioredis` | Yes |
| Job span (process lifecycle) | manual `startActiveSpan` | No |
| Producer -> worker context | `propagation.inject` / `extract` | No |
| Job counters / duration | manual metrics instruments | No |
| Queue depth (waiting/active) | observable gauges over BullMQ counts | No |
The runnable source for every snippet below lives in the
[base14 examples repo](https://github.com/base-14/examples/tree/main/nodejs/nestjs-postgres)
under `src/jobs/`.
### Installation
Install the OpenTelemetry SDK, the OTLP exporter, and the IORedis
instrumentation. BullMQ uses [ioredis](https://github.com/redis/ioredis) under
the hood, so the IORedis instrumentation is what captures its Redis traffic.
```mdx-code-block
```
```bash showLineNumbers
npm install --save \
@opentelemetry/sdk-node \
@opentelemetry/api \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/sdk-metrics \
@opentelemetry/instrumentation-http \
@opentelemetry/instrumentation-ioredis \
@opentelemetry/resources \
@opentelemetry/semantic-conventions
```
```mdx-code-block
```
```bash showLineNumbers
yarn add \
@opentelemetry/sdk-node \
@opentelemetry/api \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/sdk-metrics \
@opentelemetry/instrumentation-http \
@opentelemetry/instrumentation-ioredis \
@opentelemetry/resources \
@opentelemetry/semantic-conventions
```
```mdx-code-block
```
```bash showLineNumbers
pnpm add \
@opentelemetry/sdk-node \
@opentelemetry/api \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/sdk-metrics \
@opentelemetry/instrumentation-http \
@opentelemetry/instrumentation-ioredis \
@opentelemetry/resources \
@opentelemetry/semantic-conventions
```
```mdx-code-block
```
### Configuration
Initialize the SDK **before** any BullMQ or ioredis code runs, so the Redis
client is wrapped. Load this file first via `node --require ./instrumentation.js`
or as the first import in your worker entry point.
```mdx-code-block
```
```typescript showLineNumbers title="src/instrumentation.ts"
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
const otlpEndpoint =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318';
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'bullmq-worker',
}),
traceExporter: new OTLPTraceExporter({
url: `${otlpEndpoint}/v1/traces`,
}),
instrumentations: [
new HttpInstrumentation(),
new IORedisInstrumentation({
// Keep payloads small and free of secrets in span attributes
dbStatementSerializer: (cmdName, cmdArgs) =>
`${cmdName} ${cmdArgs.slice(0, 2).join(' ')}`,
}),
],
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown().finally(() => process.exit(0));
});
```
```mdx-code-block
```
```bash showLineNumbers title=".env"
# Service identification
OTEL_SERVICE_NAME=bullmq-worker
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development,environment=development,service.namespace=jobs
# Exporter
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4318
# BullMQ connection
REDIS_URL=redis://redis:6379
```
```mdx-code-block
```
```yaml showLineNumbers title="docker-compose.yml"
services:
worker:
build: .
command: ['node', '--require', './dist/instrumentation.js', 'dist/worker.js']
environment:
- OTEL_SERVICE_NAME=bullmq-worker
- OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4318
- REDIS_URL=redis://redis:6379
depends_on:
- redis
- scout-collector
redis:
image: redis:7-alpine
ports:
- '6379:6379'
scout-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ['--config=/etc/otel-collector-config.yaml']
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- '4318:4318'
```
```mdx-code-block
```
> All approaches export traces and metrics to the base14 Scout observability
> backend through the OTLP endpoint.
### Traces
A complete BullMQ trace has two halves that live in different processes: the
**producer** that enqueues the job during an HTTP request, and the **worker**
that processes it later. The Redis commands in between are traced automatically
by the IORedis instrumentation. The job span and the link between the two halves
are manual.
#### Automatic Redis Spans
Once `IORedisInstrumentation` is registered, every Redis command BullMQ issues -
`LPUSH`, `BRPOPLPUSH`, `HSET`, `XADD`, and so on - becomes a span. This shows you
how long enqueue and dequeue operations take and surfaces Redis latency, but it
does **not** group the work of a single job or connect the producer to the
worker. For that, add the job span and propagate context.
#### Propagate Context on Enqueue
On the producer, inject the active trace context into the job payload before
calling `queue.add`. BullMQ serializes job data to Redis, so the W3C
`traceparent` travels with the job.
```typescript showLineNumbers title="src/jobs/notification.producer.ts"
import { Queue } from 'bullmq';
import { context, propagation, trace } from '@opentelemetry/api';
const notificationsQueue = new Queue('notifications', {
connection: { url: process.env.REDIS_URL },
});
export async function enqueueArticlePublished(payload: {
articleId: string;
title: string;
authorId: string;
}): Promise {
// Capture the current trace context into a carrier object
const carrier: Record = {};
propagation.inject(context.active(), carrier);
const job = await notificationsQueue.add(
'article.published',
{ ...payload, traceContext: carrier },
{
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
},
);
trace.getActiveSpan()?.setAttribute('job.id', job.id ?? '');
return job.id;
}
```
#### Start the Job Span on the Worker
In the worker, pull the carrier back out of the job data, restore it as the
parent context, and create a `CONSUMER` span for the job. Everything that runs
inside `context.with` - including the automatic Redis and database spans -
becomes a child of the job span, and the whole thing is linked back to the
request that enqueued it.
```typescript showLineNumbers title="src/jobs/notification.worker.ts"
import { Worker, Job } from 'bullmq';
import {
context,
propagation,
trace,
metrics,
SpanKind,
SpanStatusCode,
} from '@opentelemetry/api';
const tracer = trace.getTracer('notification-worker');
const meter = metrics.getMeter('notification-worker');
const jobsCompleted = meter.createCounter('jobs.completed', {
description: 'Number of jobs completed successfully',
});
const jobsFailed = meter.createCounter('jobs.failed', {
description: 'Number of jobs that failed',
});
const jobDuration = meter.createHistogram('jobs.duration', {
description: 'Duration of job processing in milliseconds',
unit: 'ms',
});
new Worker(
'notifications',
async (job: Job) => {
const startTime = Date.now();
const { traceContext, ...payload } = job.data;
const parentContext = propagation.extract(
context.active(),
traceContext ?? {},
);
await context.with(parentContext, async () => {
await tracer.startActiveSpan(
'job.process',
{
kind: SpanKind.CONSUMER,
attributes: {
'job.id': job.id,
'job.name': job.name,
'job.queue': job.queueName,
'job.attempt': job.attemptsMade + 1,
},
},
async (span) => {
try {
await handleArticlePublished(payload);
span.setStatus({ code: SpanStatusCode.OK });
jobsCompleted.add(1, { queue: job.queueName });
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : String(error),
});
span.recordException(
error instanceof Error ? error : new Error(String(error)),
);
jobsFailed.add(1, { queue: job.queueName });
throw error;
} finally {
const duration = Date.now() - startTime;
jobDuration.record(duration, { queue: job.queueName });
span.setAttribute('job.duration_ms', duration);
span.end();
}
},
);
});
},
{ connection: { url: process.env.REDIS_URL } },
);
```
#### Trace Hierarchy
```text
HTTP Request Span (root: POST /articles)
├── PostgreSQL INSERT Span (create article)
├── Redis LPUSH Span (enqueue: ioredis instrumentation)
│
└── (later, in the worker process — linked via traceparent in job data)
job.process Span (CONSUMER, kind=consumer)
├── article.publish.update Span
│ └── PostgreSQL UPDATE Span
└── notification.send Span
```
##### Reference
[Official Traces Documentation](https://opentelemetry.io/docs/concepts/signals/traces/)
### Metrics
Spans explain one job; metrics tell you about the queue as a whole - throughput,
failure rate, and backlog. Two kinds are useful for BullMQ: per-job counters and
a histogram (recorded in the worker, shown above), and queue-depth gauges
(sampled on an interval).
#### Enable the Meter Provider
If you are not already exporting metrics, add an OTLP metric reader to the SDK
config from the Configuration section:
```typescript showLineNumbers title="src/instrumentation.ts"
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
// inside new NodeSDK({ ... })
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: `${otlpEndpoint}/v1/metrics`,
}),
exportIntervalMillis: 15000,
}),
```
#### Queue-Depth Gauges
BullMQ exposes live counts per state. Read them on an interval and report them
through OpenTelemetry observable gauges. These are the metrics you alert on:
rising `waiting` means the workers cannot keep up, and rising `failed` means
something is broken downstream.
```typescript showLineNumbers title="src/jobs/queue-metrics.ts"
import { metrics } from '@opentelemetry/api';
import { Queue } from 'bullmq';
const meter = metrics.getMeter('job-queue-metrics');
const queue = new Queue('notifications', {
connection: { url: process.env.REDIS_URL },
});
let stats = { waiting: 0, active: 0, delayed: 0, failed: 0, completed: 0 };
const states = ['waiting', 'active', 'delayed', 'failed', 'completed'] as const;
for (const state of states) {
meter
.createObservableGauge(`job_queue_${state}`, {
description: `Number of ${state} jobs in the queue`,
})
.addCallback((result) =>
result.observe(stats[state], { queue: 'notifications' }),
);
}
setInterval(async () => {
const [waiting, active, delayed, failed, completed] = await Promise.all([
queue.getWaitingCount(),
queue.getActiveCount(),
queue.getDelayedCount(),
queue.getFailedCount(),
queue.getCompletedCount(),
]);
stats = { waiting, active, delayed, failed, completed };
}, 5000);
```
> View these metrics in your base14 Scout dashboard to chart throughput, failure
> ratio, and queue backlog, and to alert when `waiting` or `failed` climbs.
##### Reference
[Official Metrics Documentation](https://opentelemetry.io/docs/concepts/signals/metrics/)
### Production Configuration
In production, batch span export and tune the worker so instrumentation does not
become the bottleneck.
```typescript showLineNumbers title="src/instrumentation.ts"
import { NodeSDK } from '@opentelemetry/sdk-node';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';
import { resourceFromAttributes } from '@opentelemetry/resources';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'bullmq-worker',
[ATTR_SERVICE_VERSION]: process.env.APP_VERSION || '1.0.0',
'deployment.environment.name': process.env.NODE_ENV || 'development',
'environment': process.env.NODE_ENV || 'development',
}),
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter({
url: `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
}),
{
maxQueueSize: 2048,
maxExportBatchSize: 512,
scheduledDelayMillis: 5000,
},
),
],
instrumentations: [new IORedisInstrumentation()],
});
sdk.start();
```
```dockerfile showLineNumbers title="Dockerfile"
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
ENV NODE_ENV=production
ENV OTEL_SERVICE_NAME=bullmq-worker
# Load instrumentation before the worker
CMD ["node", "--require", "./dist/instrumentation.js", "dist/worker.js"]
```
For high-throughput queues, run multiple worker processes and set BullMQ
`concurrency` per worker. Each process exports its own spans and metrics with a
distinct `service.instance.id`, and the job spans still link back to the
originating request through the propagated context.
### Framework-Specific Features
#### Framework Integration
BullMQ's `Queue` and `Worker` API is the same regardless of the web framework
in front of it, so the instrumentation code is identical: `propagation.inject`
before `queue.add` on the producer, then `propagation.extract` → `context.with`
→ `startActiveSpan` on the worker. Only three things vary - where the producer
lives, whether the worker shares the web process, and how the SDK is bootstrapped.
| Framework | Producer injects in | Worker runs as | SDK bootstrap |
|-----------|--------------------|----------------|---------------|
| Express | route/service helper | standalone process | `telemetry.ts` preloaded (`node -r ./telemetry`) |
| Fastify | `jobs/tasks/*.ts` | standalone process | `telemetry.ts` preloaded |
| Hono | `jobs/tasks/*.ts` | standalone process | `telemetry.ts` preloaded |
| Next.js | `lib/queue.ts` wrapper | separate process (not the Next server) | `instrumentation.ts` `register()` hook |
| NestJS | `@InjectQueue` service | `@Processor` / `WorkerHost` | `instrumentation.ts` preloaded |
The span code does not change between these - copy the producer and worker
snippets from the [Traces](#traces) section as-is. NestJS is the only one where
the decorators move where that code sits, shown below. Working versions of each
live in the [base14 examples repo](https://github.com/base-14/examples/tree/main/nodejs).
#### NestJS (@nestjs/bullmq)
With `@nestjs/bullmq`, the producer is a service that injects the queue and the
worker is a `WorkerHost`. The OpenTelemetry calls are identical - inject on
enqueue, extract and `startActiveSpan` in `process`.
```typescript showLineNumbers title="src/jobs/notification.service.ts"
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { context, propagation, trace } from '@opentelemetry/api';
@Injectable()
export class NotificationService {
constructor(
@InjectQueue('notifications') private notificationsQueue: Queue,
) {}
async notifyArticlePublished(articleId: string, title: string) {
const carrier: Record = {};
propagation.inject(context.active(), carrier);
const job = await this.notificationsQueue.add(
'article.published',
{ articleId, title, traceContext: carrier },
{ attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
);
trace.getActiveSpan()?.setAttribute('job.id', job.id ?? '');
return job.id;
}
}
```
```typescript showLineNumbers title="src/jobs/notification.processor.ts"
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import {
context,
propagation,
trace,
SpanKind,
SpanStatusCode,
} from '@opentelemetry/api';
const tracer = trace.getTracer('notification-processor');
@Processor('notifications')
export class NotificationProcessor extends WorkerHost {
async process(job: Job): Promise {
const { traceContext, ...payload } = job.data;
const parentContext = propagation.extract(context.active(), traceContext);
await context.with(parentContext, async () => {
await tracer.startActiveSpan(
'job.process',
{ kind: SpanKind.CONSUMER, attributes: { 'job.id': job.id } },
async (span) => {
try {
await this.handle(payload);
span.setStatus({ code: SpanStatusCode.OK });
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw error;
} finally {
span.end();
}
},
);
});
}
private async handle(payload: unknown): Promise {
// business logic
}
}
```
See the full NestJS implementation, including metrics and structured logs, in
the [base14 example](https://github.com/base-14/examples/tree/main/nodejs/nestjs-postgres).
#### FlowProducer and Child Jobs
BullMQ `FlowProducer` creates parent/child job trees. Inject context into each
job's data the same way; the child worker extracts its own job's carrier so each
node in the flow links back to the request that started it.
### Custom Instrumentation
Add child spans inside the job span for the meaningful steps of your work, so a
slow job points you at the exact operation:
```typescript showLineNumbers title="src/jobs/notification.processor.ts"
import { trace, SpanStatusCode } from '@opentelemetry/api';
const tracer = trace.getTracer('notification-processor');
async function sendNotification(recipient: string): Promise {
await tracer.startActiveSpan(
'notification.send',
{ attributes: { 'notification.recipient': recipient } },
async (span) => {
try {
await deliver(recipient);
span.setStatus({ code: SpanStatusCode.OK });
} finally {
span.end();
}
},
);
}
```
To correlate logs with traces, attach the active trace and span IDs to log
records:
```typescript showLineNumbers
import { trace } from '@opentelemetry/api';
const span = trace.getActiveSpan();
logger.info('Article published', {
'trace.id': span?.spanContext().traceId,
'span.id': span?.spanContext().spanId,
});
```
### Running Your Application
Start Redis, the collector, the worker, and the producer, then trigger a job and
follow it in Scout.
```bash showLineNumbers
# Start infrastructure
docker compose up -d redis scout-collector
# Run the worker (instrumentation loaded first)
node --require ./dist/instrumentation.js dist/worker.js
# In another terminal, run the producer / API
node --require ./dist/instrumentation.js dist/server.js
# Enqueue a job
curl -X POST http://localhost:3000/articles \
-H 'Content-Type: application/json' \
-d '{"title":"Hello","authorId":"u_1"}'
```
Expected span hierarchy for the request and the job (two linked traces): the
`POST /articles` request span with a Redis `LPUSH` child, then a `job.process`
consumer span in the worker carrying the same trace ID, with its own database
and `notification.send` children.
### Troubleshooting
#### Issue: Worker jobs appear as separate, disconnected traces
The most common problem. The worker is starting a new root span because no
context was propagated. Confirm the producer calls `propagation.inject` into the
job data **and** the worker calls `propagation.extract` and runs the span inside
`context.with(parentContext, ...)`. The carrier key in the job payload
(`traceContext`) must match on both sides.
#### Issue: No Redis spans at all
The SDK started after the BullMQ/ioredis modules were imported, so the client
was never wrapped. Load `instrumentation.ts` first - use
`node --require ./instrumentation.js` or make it the very first import in your
entry file.
#### Issue: Job span has no children for database or HTTP calls
Those calls are running outside `context.with`. Make sure all of the job's work
happens inside the `startActiveSpan` callback so the active context is set when
the database and HTTP instrumentations create their spans.
#### Issue: Retried jobs are confusing in the trace view
Each attempt runs the worker again and creates a new `job.process` span linked to
the original enqueue. Use the `job.attempt` attribute (`job.attemptsMade + 1`) to
distinguish attempts, and set the span status to ERROR on the failing attempts.
#### Issue: Queue-depth gauges always read zero
The `Queue` instance used for metrics must point at the same Redis connection and
queue name as the workers. Verify `REDIS_URL` and the queue name match, and that
the sampling `setInterval` is actually running in a live process.
### Security Considerations
- **Do not put secrets in job data.** Job payloads are stored in Redis and, if
you add them as span attributes, exported to your backend. Pass identifiers,
not credentials or PII.
- **Trim Redis statement attributes.** The `dbStatementSerializer` shown in the
SDK config truncates command arguments so queue contents are not captured
verbatim in spans.
- **Secure Redis.** Use authentication and TLS (`rediss://`) in production;
BullMQ inherits the connection security you configure on ioredis.
- **Be deliberate about attributes.** Only attach business fields you are
comfortable storing centrally (article IDs are fine; email bodies are not).
### Performance Considerations
- **Overhead.** Expect roughly 0.5-2ms added per job from span creation and
context propagation, low single-digit CPU increase, and 15-35MB additional
memory on the worker process. Redis round trips dominate job latency, not the
instrumentation.
- **Batch export.** Use `BatchSpanProcessor` (Production Configuration) so export
happens off the hot path.
- **Sample queue metrics sensibly.** A 5s gauge interval is plenty; polling
BullMQ counts too aggressively adds Redis load for little benefit.
- **Filter noisy Redis commands** if needed via the IORedis instrumentation
hooks, but keep the queue operations you care about.
### FAQ
#### Does OpenTelemetry auto-instrument BullMQ?
No. There is no dedicated BullMQ package in `auto-instrumentations-node`. The
Redis commands BullMQ issues are traced automatically by
`instrumentation-ioredis`, and you add manual spans plus context propagation for
job-level, producer-to-worker tracing.
#### How do I trace a BullMQ job from producer to worker?
Inject the active context into the job data with `propagation.inject` on
enqueue, then extract it with `propagation.extract` in the worker and run the job
span inside `context.with`. This stitches the enqueue and process spans into one
trace.
#### Why do my BullMQ jobs show up as disconnected traces?
BullMQ does not carry trace context across Redis on its own. Without injecting
context into the job data and extracting it in the worker, the worker starts a
fresh root span, so the job looks like a separate trace.
#### How do I monitor BullMQ queue depth with OpenTelemetry?
Use observable gauges that read `getWaitingCount()`, `getActiveCount()`,
`getDelayedCount()`, `getFailedCount()`, and `getCompletedCount()` on an
interval, reporting one gauge per state with a `queue` attribute.
#### Does instrumentation-ioredis cover BullMQ completely?
It covers the Redis command layer, which is the transport. It does not group a
job's work into one span or link producer and worker - that is what the manual
job span and context propagation add.
#### How much overhead does OpenTelemetry add to BullMQ workers?
Roughly 0.5-2ms per job and low single-digit CPU, with 15-35MB extra memory per
worker process when using the batch span processor. The Redis and downstream
calls dominate job time.
#### Can I trace BullMQ flows and child jobs?
Yes. With `FlowProducer`, inject context into each job's data. Every child worker
extracts its own carrier, so each node in the flow links back to the originating
request.
#### How do I trace failed and retried jobs?
Each retry re-runs the worker and creates a new `job.process` span. Record the
exception and set the span status to ERROR on failures, and use the
`job.attempt` attribute to tell attempts apart.
#### Does this work with NestJS @nestjs/bullmq?
Yes. The producer service and the `WorkerHost` processor use the exact same
`propagation.inject` / `propagation.extract` and `startActiveSpan` calls; only
the BullMQ wiring differs.
#### How do I propagate context through delayed or scheduled jobs?
The same way - context is injected into the job data at enqueue time and travels
with the job in Redis regardless of how long it is delayed. The worker extracts
it whenever the job eventually runs.
#### Should I sample BullMQ traces?
This guide does not cover sampling. Export the spans your workers produce and let
the collector handle volume centrally.
### What's Next
- Build a queue dashboard in Scout from the `jobs.*` and `job_queue_*` metrics.
- Set alerts on `job_queue_waiting` (backlog) and the `jobs.failed` rate.
- Add child spans for the slow steps inside your jobs.
For teams standardizing background-job observability across services, base14
Scout gives you [unified traces, metrics, and logs](https://base14.io/scout) for
producers and workers in one place.
### Complete Example
A complete NestJS + BullMQ + PostgreSQL application with producer-to-worker
tracing, job metrics, queue-depth gauges, and trace-correlated logs is available
in the base14 examples repository.
```text
nestjs-postgres/
├── src/
│ ├── instrumentation / telemetry.ts # SDK + ioredis instrumentation
│ └── jobs/
│ ├── notification.service.ts # producer: inject context, enqueue
│ ├── notification.processor.ts # worker: extract context, job span
│ └── job-metrics.service.ts # queue-depth observable gauges
└── docker-compose.yml
```
```bash showLineNumbers
git clone https://github.com/base-14/examples.git
cd examples/nodejs/nestjs-postgres
npm install
docker compose up -d
npm run start:dev
```
### References
- [BullMQ Documentation](https://docs.bullmq.io/)
- [OpenTelemetry JavaScript SDK](https://opentelemetry.io/docs/languages/js/)
- [IORedis Instrumentation](https://www.npmjs.com/package/@opentelemetry/instrumentation-ioredis)
- [OpenTelemetry Context Propagation](https://opentelemetry.io/docs/concepts/context-propagation/)
### Related Guides
- [NestJS Instrumentation](./nestjs.md) - Structured TypeScript framework that
uses BullMQ for background jobs
- [Express Instrumentation](./express.md) - Enqueue jobs from an Express API
- [Fastify Instrumentation](./fastify.md) - High-performance Node.js web
framework
- [Node.js Custom Instrumentation](../custom-instrumentation/javascript-node.md)
\- Manual spans and advanced patterns
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) - Set
up the collector for local development
---
## Celery OpenTelemetry Instrumentation - Task Tracing & Propagation
Implement OpenTelemetry instrumentation for Celery applications to enable
comprehensive distributed task queue monitoring, end-to-end tracing, and
observability. This guide shows you how to auto-instrument your Celery workers
and task producers to collect traces and metrics from task execution, message
queues, and result backends using the OpenTelemetry Python SDK.
Celery is a distributed task queue rather than a web framework. It commonly runs
alongside web apps built with [FastAPI](./fast-api.md), [Flask](./flask.md), and
[Django](./django.md).
Celery applications benefit from automatic instrumentation that captures task
lifecycle events including task publishing, worker processing, retries, and
failures. With OpenTelemetry, you can trace distributed transactions from HTTP
requests through message brokers (RabbitMQ, Redis) to worker execution, monitor
task performance and queue depths, debug slow or failing tasks, and identify
bottlenecks in your async processing pipeline without significant code changes.
Whether you're implementing observability for the first time, migrating from
commercial APM solutions, or troubleshooting production issues with distributed
task queues, this guide provides production-ready configurations and best
practices for Celery OpenTelemetry instrumentation.
:::tip TL;DR
Install `opentelemetry-instrumentation-celery` and initialize the SDK with
`CeleryInstrumentor().instrument()` before starting your workers. Set
`OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_SERVICE_NAME`, then use
`opentelemetry-instrument celery worker` to auto-inject context propagation
across task producers and consumers. Traces flow end-to-end from HTTP request
through the broker (RabbitMQ or Redis) to worker execution with no changes to
your task code.
:::
> **Note:** This guide provides a practical Celery-focused overview based on the
> official OpenTelemetry documentation. For complete Python language
> information, please consult the
> [official OpenTelemetry Python documentation](https://opentelemetry.io/docs/languages/python/).
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Who This Guide Is For
This documentation is designed for:
- **Python developers**: implementing observability for Celery task queues and
distributed systems for the first time
- **DevOps engineers**: deploying Celery workers with production monitoring
requirements and distributed tracing needs
- **Engineering teams**: migrating from DataDog, New Relic, or other commercial
APM solutions to OpenTelemetry
- **Developers**: debugging slow tasks, failed retries, or tracing issues across
HTTP requests and async task execution
- **Platform teams**: standardizing observability across multiple Python
services using Celery for background processing
### Overview
This comprehensive guide demonstrates how to:
- Install and configure OpenTelemetry SDK for Celery applications
- Set up automatic instrumentation for task publishing and worker execution
- Propagate trace context across async boundaries (HTTP → Celery → Worker)
- Configure production-ready telemetry export to Scout Collector
- Implement custom instrumentation for business-critical task operations
- Collect and analyze traces, metrics, and logs from distributed task processing
- Deploy instrumented Celery workers to development, staging, and production
- Troubleshoot common instrumentation issues and optimize performance
- Secure sensitive data in telemetry exports
### Prerequisites
Before starting, ensure you have:
- **Python 3.9 or later** installed
- Python 3.11+ is recommended for best performance
- Python 3.13 is fully supported
- **Celery 5.3 or later** installed
- Celery 5.4+ is recommended for optimal OpenTelemetry support
- **Message broker** configured (RabbitMQ or Redis)
- **Scout Collector** configured and accessible
- See
[Docker Compose Setup](../../collector-setup/docker-compose-example.md) for
local development
- Production deployments should use a dedicated Scout Collector instance
- Basic understanding of OpenTelemetry concepts (traces, spans, attributes)
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version |
| ---------------- | --------------- | ------------------- |
| Python | 3.9.0 | 3.11.0+ |
| Celery | 5.3.0 | 5.4.0+ |
| RabbitMQ | 3.8.0 | 3.13.0+ |
| Redis | 6.0.0 | 7.0.0+ |
| opentelemetry-\* | 1.20.0 | 1.27+ |
### Required Packages
Install the following packages using pip or add them to your `requirements.txt`:
```plaintext showLineNumbers title="requirements.txt"
opentelemetry-api
opentelemetry-sdk
opentelemetry-exporter-otlp
opentelemetry-instrumentation-celery
```
For comprehensive auto-instrumentation including Redis, SQLAlchemy, and other
libraries commonly used with Celery:
```plaintext showLineNumbers title="requirements.txt"
opentelemetry-distro
opentelemetry-exporter-otlp
opentelemetry-instrumentation-celery
opentelemetry-instrumentation-redis
opentelemetry-instrumentation-sqlalchemy
opentelemetry-instrumentation-logging
```
Install with pip:
```bash
pip install opentelemetry-distro opentelemetry-exporter-otlp \
opentelemetry-instrumentation-celery opentelemetry-instrumentation-redis
```
Or using Poetry:
```bash
poetry add opentelemetry-distro opentelemetry-exporter-otlp \
opentelemetry-instrumentation-celery opentelemetry-instrumentation-redis
```
### Configuration
OpenTelemetry Celery instrumentation can be configured using multiple approaches
depending on your deployment requirements and preferences. Choose the method
that best fits your application architecture.
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
The recommended approach uses the `opentelemetry-instrument` CLI command which
automatically instruments all supported libraries without code changes:
```bash showLineNumbers
# Start Celery worker with auto-instrumentation
opentelemetry-instrument celery -A myapp.tasks worker --loglevel=info
```
Configure via environment variables:
```bash showLineNumbers title=".env"
OTEL_SERVICE_NAME=celery-worker
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true
```
This approach automatically instruments:
- **Celery**: Task execution, worker operations, task publishing
- **Redis**: Result backend operations, broker commands
- **SQLAlchemy**: Database queries within tasks
- **Logging**: Trace-correlated log records
```mdx-code-block
```
For more control over instrumentation timing, use Celery's `worker_process_init`
signal:
```python showLineNumbers title="myapp/telemetry.py"
from celery.signals import worker_process_init
from opentelemetry.instrumentation.celery import CeleryInstrumentor
@worker_process_init.connect(weak=False)
def init_celery_tracing(*args, **kwargs):
"""Initialize tracing for Celery worker processes."""
CeleryInstrumentor().instrument()
```
Import this module in your Celery app to ensure it runs on worker startup:
```python showLineNumbers title="myapp/tasks.py"
from celery import Celery
from . import telemetry # Import to trigger signal registration
app = Celery("tasks", broker="amqp://localhost")
@app.task
def process_task(task_id: int):
return {"task_id": task_id, "status": "completed"}
```
```mdx-code-block
```
For full control over OpenTelemetry configuration:
```python showLineNumbers title="myapp/telemetry.py"
import os
from celery.signals import worker_process_init
from opentelemetry import trace, metrics
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
from opentelemetry.instrumentation.celery import CeleryInstrumentor
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.resource import ResourceAttributes
def init_telemetry():
"""Initialize OpenTelemetry tracing and metrics."""
service_name = os.getenv("OTEL_SERVICE_NAME", "celery-worker")
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
resource = Resource(attributes={
ResourceAttributes.SERVICE_NAME: service_name,
ResourceAttributes.SERVICE_VERSION: "1.0.0",
})
# Setup trace provider with batch processor
trace.set_tracer_provider(TracerProvider(resource=resource))
tracer_provider = trace.get_tracer_provider()
span_exporter = OTLPSpanExporter(endpoint=f"{endpoint}/v1/traces")
span_processor = BatchSpanProcessor(span_exporter)
tracer_provider.add_span_processor(span_processor)
# Setup metrics provider
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=f"{endpoint}/v1/metrics")
)
metrics.set_meter_provider(
MeterProvider(resource=resource, metric_readers=[metric_reader])
)
@worker_process_init.connect(weak=False)
def init_celery_tracing(*args, **kwargs):
"""Initialize tracing for Celery worker processes."""
init_telemetry()
CeleryInstrumentor().instrument()
```
```mdx-code-block
```
For containerized deployments, rely entirely on environment variables with
minimal code:
```python showLineNumbers title="myapp/telemetry.py"
from celery.signals import worker_process_init
from opentelemetry.instrumentation.celery import CeleryInstrumentor
@worker_process_init.connect(weak=False)
def init_celery_tracing(*args, **kwargs):
CeleryInstrumentor().instrument()
```
Configure all settings via environment:
```bash showLineNumbers title=".env"
# Service identification
OTEL_SERVICE_NAME=celery-worker
OTEL_SERVICE_VERSION=1.0.0
# Exporter configuration
OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_COMPRESSION=gzip
# Enable all exporters
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
# Resource attributes
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development,environment=development,service.namespace=myapp
```
```mdx-code-block
```
#### Scout Collector Integration
When using Scout Collector, configure your Celery application to send telemetry
data to the Scout Collector endpoint:
```python showLineNumbers title="myapp/telemetry.py"
import os
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.resource import ResourceAttributes
def init_telemetry():
"""Initialize OpenTelemetry with Scout Collector."""
resource = Resource(attributes={
ResourceAttributes.SERVICE_NAME: os.getenv("OTEL_SERVICE_NAME", "celery-worker"),
ResourceAttributes.SERVICE_VERSION: os.getenv("APP_VERSION", "1.0.0"),
})
# Scout Collector endpoint
scout_endpoint = os.getenv("SCOUT_COLLECTOR_ENDPOINT", "http://localhost:4318")
trace.set_tracer_provider(TracerProvider(resource=resource))
tracer_provider = trace.get_tracer_provider()
span_exporter = OTLPSpanExporter(endpoint=f"{scout_endpoint}/v1/traces")
span_processor = BatchSpanProcessor(span_exporter)
tracer_provider.add_span_processor(span_processor)
```
> **Scout Dashboard Integration**: After configuration, your Celery task traces
> will appear in the Scout Dashboard. Navigate to the Traces section to view
> task execution flows, identify slow tasks, and analyze distributed
> transactions across your services.
### Production Configuration
Production deployments require additional configuration for optimal performance,
reliability, and resource utilization.
#### Batch Span Processor (Recommended for Production)
The `BatchSpanProcessor` is essential for production as it reduces network
overhead by batching span exports:
```python showLineNumbers title="myapp/telemetry.py"
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.resource import ResourceAttributes
import os
def init_telemetry():
"""Initialize OpenTelemetry with production settings."""
resource = Resource(attributes={
ResourceAttributes.SERVICE_NAME: os.getenv("OTEL_SERVICE_NAME"),
ResourceAttributes.SERVICE_VERSION: os.getenv("APP_VERSION", "1.0.0"),
ResourceAttributes.DEPLOYMENT_ENVIRONMENT: os.getenv("ENVIRONMENT", "development"),
})
trace.set_tracer_provider(TracerProvider(resource=resource))
tracer_provider = trace.get_tracer_provider()
# Configure batch processor for production
span_processor = BatchSpanProcessor(
OTLPSpanExporter(
endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
),
max_queue_size=2048, # Maximum spans in queue
schedule_delay_millis=5000, # Export every 5 seconds
export_timeout_millis=30000, # 30 second timeout
max_export_batch_size=512 # Export up to 512 spans at once
)
tracer_provider.add_span_processor(span_processor)
```
#### Resource Attributes
Add rich context to all telemetry data:
```python showLineNumbers title="myapp/telemetry.py"
import socket
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.resource import ResourceAttributes
resource = Resource(attributes={
ResourceAttributes.SERVICE_NAME: os.getenv("OTEL_SERVICE_NAME", "celery-worker"),
ResourceAttributes.SERVICE_VERSION: os.getenv("APP_VERSION", "1.0.0"),
ResourceAttributes.DEPLOYMENT_ENVIRONMENT: os.getenv("ENVIRONMENT", "development"),
ResourceAttributes.SERVICE_NAMESPACE: os.getenv("SERVICE_NAMESPACE", "myapp"),
ResourceAttributes.SERVICE_INSTANCE_ID: socket.gethostname(),
ResourceAttributes.HOST_NAME: socket.gethostname(),
"cloud.provider": os.getenv("CLOUD_PROVIDER", "aws"),
"cloud.region": os.getenv("AWS_REGION", "us-east-1"),
"k8s.pod.name": os.getenv("K8S_POD_NAME"),
"k8s.namespace.name": os.getenv("K8S_NAMESPACE"),
})
```
#### Production Environment Variables
```bash showLineNumbers title=".env.production"
# Service Configuration
OTEL_SERVICE_NAME=celery-worker-production
APP_VERSION=2.1.3
SERVICE_NAMESPACE=production
ENVIRONMENT=demo
# Scout Collector Endpoint
OTEL_EXPORTER_OTLP_ENDPOINT=https://scout-collector.example.com:4318
# Batch Processor Settings
OTEL_BSP_MAX_QUEUE_SIZE=2048
OTEL_BSP_SCHEDULE_DELAY=5000
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
# Exporter Settings
OTEL_EXPORTER_OTLP_COMPRESSION=gzip
OTEL_EXPORTER_OTLP_TIMEOUT=30000
# Celery Configuration
CELERY_BROKER_URL=amqp://user:pass@rabbitmq:5672//
CELERY_RESULT_BACKEND=redis://redis:6379/0
# Infrastructure Context
CLOUD_PROVIDER=aws
AWS_REGION=us-east-1
```
#### Docker Production Configuration
```dockerfile showLineNumbers title="Dockerfile"
FROM python:3.13-slim
RUN groupadd -r celeryuser && useradd -r -g celeryuser -m celeryuser
WORKDIR /app
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
USER celeryuser
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/home/celeryuser/.local/bin:$PATH"
COPY --chown=celeryuser:celeryuser pyproject.toml poetry.lock* ./
RUN poetry install --no-root
COPY --chown=celeryuser:celeryuser . .
```
```yaml showLineNumbers title="compose.yaml"
services:
celery_worker:
build: .
command: poetry run opentelemetry-instrument celery -A myapp.tasks worker --loglevel=info
environment:
OTEL_SERVICE_NAME: celery-worker
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
OTEL_TRACES_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_LOGS_EXPORTER: otlp
OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED: "true"
CELERY_BROKER_URL: amqp://guest:guest@rabbitmq:5672//
CELERY_RESULT_BACKEND: redis://redis:6379/0
depends_on:
- rabbitmq
- redis
- otel-collector
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otelcol-config.yaml"]
volumes:
- ./config/otelcol-config.yaml:/etc/otelcol-config.yaml:ro
ports:
- "4318:4318"
- "4317:4317"
```
### Distributed Tracing Across Async Boundaries
The most critical aspect of Celery instrumentation is propagating trace context
across async boundaries. Without context propagation, Celery workers start new
traces, breaking the correlation between HTTP requests and task execution.
#### Understanding Context Propagation
```text
POST /tasks/ Trace ID: abc123
├── INSERT task_db (PostgreSQL)
├── apply_async/process_task ─► RabbitMQ ─► run/process_task
├── process_task
│ └── heavy_processing
└── SETEX (Redis)
```
Without context propagation, the worker would create a new trace ID, making it
impossible to correlate the HTTP request with task execution.
#### Injecting Trace Context (Producer Side)
When publishing tasks from a web framework (FastAPI, Django, Flask), inject the
trace context into Celery task headers:
```python showLineNumbers title="app/api/endpoints.py"
from fastapi import FastAPI, Depends
from opentelemetry.propagate import inject
from . import tasks
app = FastAPI()
@app.post("/tasks/")
def create_task(task_data: dict):
# Create database record, etc.
db_task = create_task_record(task_data)
# Inject trace context into Celery task headers
headers = {}
inject(headers)
# Publish task with trace context
tasks.process_task.apply_async(
args=[db_task.id],
headers=headers
)
return {"task_id": db_task.id, "status": "queued"}
```
#### Extracting Context (Worker Side)
The Celery instrumentation automatically extracts context from task headers when
properly configured. For custom span creation within tasks:
```python showLineNumbers title="myapp/tasks.py"
from celery import Celery
from opentelemetry import trace
from opentelemetry.propagate import extract
from opentelemetry.context import attach, detach
celery = Celery("tasks", broker="amqp://localhost")
@celery.task(bind=True)
def process_task(self, task_id: int):
tracer = trace.get_tracer(__name__)
# Create custom span within the propagated context
with tracer.start_as_current_span("process_task") as span:
span.set_attribute("task.id", task_id)
# Business logic with nested spans
with tracer.start_span("heavy_processing") as processing_span:
result = perform_processing(task_id)
processing_span.set_attribute("processing.duration_ms", result.duration)
span.set_attribute("task.status", "completed")
return {"task_id": task_id, "status": "completed"}
```
#### Complete Producer-Consumer Example
```python showLineNumbers title="app/main.py"
from fastapi import FastAPI
from opentelemetry.propagate import inject
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from . import tasks
from .telemetry import setup_telemetry
app = FastAPI()
setup_telemetry(app)
@app.post("/orders/")
def create_order(order_data: dict):
"""Create order and queue async processing."""
order = Order.create(**order_data)
# Propagate trace context to Celery
headers = {}
inject(headers)
# Queue multiple tasks with same trace context
tasks.validate_inventory.apply_async(args=[order.id], headers=headers)
tasks.process_payment.apply_async(args=[order.id], headers=headers)
tasks.send_confirmation.apply_async(args=[order.id], headers=headers)
return {"order_id": order.id}
```
```python showLineNumbers title="myapp/tasks.py"
from celery import Celery
from opentelemetry import trace
import logging
logger = logging.getLogger(__name__)
celery = Celery("tasks")
@celery.task
def validate_inventory(order_id: int):
logger.info(f"Validating inventory for order {order_id}")
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("validate_inventory") as span:
span.set_attribute("order.id", order_id)
# Validation logic
return {"order_id": order_id, "inventory_valid": True}
@celery.task
def process_payment(order_id: int):
logger.info(f"Processing payment for order {order_id}")
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("process_payment") as span:
span.set_attribute("order.id", order_id)
# Payment logic
return {"order_id": order_id, "payment_status": "completed"}
@celery.task
def send_confirmation(order_id: int):
logger.info(f"Sending confirmation for order {order_id}")
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("send_confirmation") as span:
span.set_attribute("order.id", order_id)
# Email logic
return {"order_id": order_id, "email_sent": True}
```
### Custom Manual Instrumentation
While automatic instrumentation covers task lifecycle events, add custom
instrumentation for business logic and performance-critical operations.
#### Creating Custom Spans in Tasks
```python showLineNumbers title="myapp/tasks.py"
from celery import Celery
from opentelemetry import trace
import time
celery = Celery("tasks")
@celery.task
def generate_report(report_id: int, params: dict):
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("generate_report") as span:
span.set_attribute("report.id", report_id)
span.set_attribute("report.type", params.get("type", "standard"))
# Data gathering phase
with tracer.start_span("gather_data") as data_span:
data = gather_report_data(report_id, params)
data_span.set_attribute("data.records_count", len(data))
data_span.add_event("Data gathered", attributes={
"records": len(data)
})
# Processing phase
with tracer.start_span("process_data") as process_span:
processed = process_data(data)
process_span.set_attribute("processing.duration_ms", processed.duration)
# Rendering phase
with tracer.start_span("render_report") as render_span:
report = render_report(processed, params.get("format", "pdf"))
render_span.set_attribute("report.size_bytes", len(report))
span.set_status(trace.Status(trace.StatusCode.OK))
return {"report_id": report_id, "status": "completed"}
```
#### Adding Attributes to Current Span
```python showLineNumbers title="myapp/tasks.py"
from opentelemetry import trace
@celery.task(bind=True)
def process_order(self, order_id: int):
current_span = trace.get_current_span()
# Add business context
current_span.set_attributes({
"order.id": order_id,
"celery.task.name": self.name,
"celery.task.id": self.request.id,
"celery.task.retries": self.request.retries,
})
order = Order.get(order_id)
current_span.set_attributes({
"order.total": order.total,
"order.items_count": len(order.items),
"customer.tier": order.customer.tier,
})
return process(order)
```
#### Exception Handling and Error Tracking
```python showLineNumbers title="myapp/tasks.py"
from celery import Celery
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
celery = Celery("tasks")
@celery.task(bind=True, max_retries=3)
def risky_task(self, data: dict):
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("risky_task") as span:
span.set_attribute("task.data_size", len(str(data)))
try:
result = perform_risky_operation(data)
span.set_status(Status(StatusCode.OK))
return result
except TransientError as e:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
span.set_attribute("error.retryable", True)
# Retry with exponential backoff
raise self.retry(exc=e, countdown=2 ** self.request.retries)
except PermanentError as e:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
span.set_attribute("error.retryable", False)
raise
```
#### Custom Business Metrics
```python showLineNumbers title="myapp/metrics.py"
from opentelemetry import metrics
meter = metrics.get_meter("myapp.tasks", "1.0.0")
# Task execution counter
tasks_executed = meter.create_counter(
"tasks.executed",
unit="tasks",
description="Total number of tasks executed"
)
# Task duration histogram
task_duration = meter.create_histogram(
"tasks.duration",
unit="ms",
description="Task execution duration"
)
# Active tasks gauge
active_tasks = meter.create_up_down_counter(
"tasks.active",
unit="tasks",
description="Currently executing tasks"
)
```
```python showLineNumbers title="myapp/tasks.py"
import time
from .metrics import tasks_executed, task_duration, active_tasks
@celery.task
def monitored_task(task_id: int):
active_tasks.add(1, attributes={"task.type": "monitored"})
start_time = time.time()
try:
result = perform_work(task_id)
tasks_executed.add(1, attributes={
"task.type": "monitored",
"task.status": "success"
})
return result
except Exception as e:
tasks_executed.add(1, attributes={
"task.type": "monitored",
"task.status": "error",
"error.type": type(e).__name__
})
raise
finally:
duration_ms = (time.time() - start_time) * 1000
task_duration.record(duration_ms, attributes={"task.type": "monitored"})
active_tasks.add(-1, attributes={"task.type": "monitored"})
```
### Running Your Instrumented Application
#### Development Mode
For local development with console output:
```bash
# Set environment variables
export OTEL_SERVICE_NAME=celery-worker-dev
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_TRACES_EXPORTER=console
export OTEL_METRICS_EXPORTER=console
# Start worker with auto-instrumentation
opentelemetry-instrument celery -A myapp.tasks worker --loglevel=debug
```
#### Production Mode
```bash
# Set production environment variables
export OTEL_SERVICE_NAME=celery-worker-production
export APP_VERSION=2.1.0
export OTEL_EXPORTER_OTLP_ENDPOINT=https://scout-collector.example.com:4318
export OTEL_TRACES_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true
# Start worker
opentelemetry-instrument celery -A myapp.tasks worker \
--loglevel=info \
--concurrency=4 \
--prefetch-multiplier=4
```
#### Docker Deployment
```bash
# Build the image
docker build -t celery-worker:latest .
# Run worker with Scout Collector
docker run -d \
--name celery-worker \
-e OTEL_SERVICE_NAME=celery-worker \
-e OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4318 \
-e CELERY_BROKER_URL=amqp://guest:guest@rabbitmq:5672// \
-e CELERY_RESULT_BACKEND=redis://redis:6379/0 \
celery-worker:latest
```
### Troubleshooting
#### Verifying OpenTelemetry Installation
Test your OpenTelemetry configuration:
```python
# test_telemetry.py
from opentelemetry import trace
tracer = trace.get_tracer("test")
with tracer.start_as_current_span("test_span") as span:
span.set_attribute("test", "value")
print(f"OpenTelemetry is working!")
print(f"Tracer provider: {trace.get_tracer_provider().__class__.__name__}")
print(f"Active span: {span.name}")
```
Run with instrumentation:
```bash
opentelemetry-instrument python test_telemetry.py
```
#### Health Check Task
Create a health check task to verify telemetry export:
```python showLineNumbers title="myapp/tasks.py"
from celery import Celery
from opentelemetry import trace
celery = Celery("tasks")
@celery.task
def health_check():
"""Health check task that creates a test span."""
tracer = trace.get_tracer("health_check")
with tracer.start_as_current_span("health_check_task") as span:
span.set_attribute("service.name", "celery-worker")
span.set_attribute("health.status", "ok")
return {
"status": "ok",
"tracer_provider": trace.get_tracer_provider().__class__.__name__,
}
```
#### Debug Mode
Enable debug logging:
```bash
export OTEL_LOG_LEVEL=debug
export OTEL_PYTHON_LOG_LEVEL=debug
opentelemetry-instrument celery -A myapp.tasks worker --loglevel=debug
```
#### Common Issues
##### Issue: No traces appearing in Scout Dashboard
**Solutions:**
1. Verify Scout Collector endpoint is reachable:
```bash
curl -v http://scout-collector:4318/v1/traces
```
2. Check environment variables are set:
```bash
echo $OTEL_EXPORTER_OTLP_ENDPOINT
echo $OTEL_SERVICE_NAME
```
3. Enable debug logging and check for export errors
4. Verify the worker is using `opentelemetry-instrument` command
##### Issue: Traces not correlated between HTTP requests and Celery tasks
**Solutions:**
1. Ensure trace context is injected when publishing tasks:
```python
headers = {}
inject(headers)
task.apply_async(args=[...], headers=headers)
```
2. Verify Celery instrumentation is installed:
```bash
pip show opentelemetry-instrumentation-celery
```
3. Check that both producer and worker use the same OTLP endpoint
##### Issue: Missing task execution spans
**Solutions:**
1. Ensure `worker_process_init` signal is properly connected
2. Verify instrumentation runs before task execution
3. Check that `CeleryInstrumentor().instrument()` is called
##### Issue: High memory usage in workers
**Solutions:**
1. Use `BatchSpanProcessor` instead of `SimpleSpanProcessor`
2. Reduce `max_queue_size` in BatchSpanProcessor
3. Increase `schedule_delay_millis` to batch more spans
### Security Considerations
#### Protecting Sensitive Data
Avoid adding sensitive information to span attributes:
```python
# Bad - exposes sensitive data
span.set_attributes({
"user.password": user.password, # Never!
"payment.card_number": card_number, # Never!
"user.ssn": social_security_number, # Never!
})
# Good - uses safe identifiers
span.set_attributes({
"user.id": user.id,
"payment.status": "completed",
"payment.provider": "stripe",
})
```
#### Sanitizing Task Arguments
Be careful with task arguments that may contain sensitive data:
```python showLineNumbers title="myapp/tasks.py"
@celery.task
def process_user_data(user_id: int, data: dict):
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("process_user_data") as span:
# Good - only record safe identifiers
span.set_attribute("user.id", user_id)
span.set_attribute("data.keys", list(data.keys()))
# Bad - never record raw user data
# span.set_attribute("user.data", str(data))
return process(user_id, data)
```
#### Filtering Sensitive Headers
Configure instrumentation to skip sensitive headers:
```python showLineNumbers
from opentelemetry.instrumentation.celery import CeleryInstrumentor
CeleryInstrumentor().instrument(
# Skip recording certain headers
request_hook=lambda span, task_id, args, kwargs: None,
)
```
#### Compliance Considerations
For applications handling regulated data (GDPR, HIPAA, PCI-DSS):
- Never include personally identifiable information (PII) in spans
- Use hashed or anonymized identifiers
- Configure data retention policies in Scout Dashboard
- Audit span attributes regularly for sensitive data leaks
- Consider using span sampling for high-volume sensitive operations
### Performance Considerations
#### Expected Performance Impact
OpenTelemetry instrumentation adds minimal overhead:
- **Average latency increase**: 0.5-2ms per task
- **CPU overhead**: Less than 1% with BatchSpanProcessor
- **Memory overhead**: ~30-50MB depending on queue size
**Impact varies based on:**
- Number of enabled instrumentations
- Span processor type (Batch vs Simple)
- Task execution volume
- Number of custom spans per task
#### Optimization Best Practices
##### 1. Use BatchSpanProcessor in Production
```python
# Good - batches exports, low overhead
span_processor = BatchSpanProcessor(exporter)
# Bad - exports every span immediately
span_processor = SimpleSpanProcessor(exporter)
```
##### 2. Limit Custom Span Creation
```python
# Good - single span for task
with tracer.start_as_current_span("process_order") as span:
validate(order)
charge(order)
fulfill(order)
# Avoid - excessive spans for simple operations
with tracer.start_as_current_span("process_order"):
with tracer.start_span("validate"):
validate(order)
with tracer.start_span("charge"):
charge(order)
with tracer.start_span("fulfill"):
fulfill(order)
```
##### 3. Conditional Span Recording
```python
span = trace.get_current_span()
# Only compute expensive attributes if recording
if span.is_recording():
span.set_attribute("data.summary", expensive_computation())
```
##### 4. Optimize Attribute Sizes
```python
# Good - bounded attribute
span.set_attribute("task.result", str(result)[:1000])
# Bad - unbounded attribute
span.set_attribute("task.result", str(large_result))
```
### Frequently Asked Questions
#### Does OpenTelemetry impact Celery task performance?
OpenTelemetry adds approximately 0.5-2ms overhead per task with proper
configuration (BatchSpanProcessor). This is negligible for most workloads. For
high-frequency tasks (>1000/second), consider using sampling.
#### Which Celery versions are supported?
OpenTelemetry supports Celery 5.3+ with Python 3.9+. Celery 5.4+ with Python
3.11+ is recommended for optimal compatibility and performance.
#### How do I trace tasks across multiple services?
Use `inject()` when publishing tasks and ensure all services send telemetry to
the same Scout Collector. The trace context is automatically propagated through
Celery task headers.
#### Can I use OpenTelemetry with Celery Beat (scheduled tasks)?
Yes! Celery Beat scheduled tasks are automatically instrumented. Each scheduled
execution creates a new trace. For correlation with external triggers, inject
context when scheduling dynamic tasks.
#### How do I monitor task retries?
Retries are automatically captured as span events. Use custom attributes to
track retry counts:
```python
span.set_attribute("celery.task.retries", self.request.retries)
span.set_attribute("celery.task.max_retries", self.max_retries)
```
#### Can I use both RabbitMQ and Redis as brokers?
Yes, OpenTelemetry instruments both brokers. The `rabbitmq` and `redis` receiver
components in the collector can gather infrastructure metrics from both.
#### How do I correlate Celery logs with traces?
Enable log instrumentation to automatically inject trace IDs:
```bash
export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true
```
Logs will include `trace_id` and `span_id` for correlation in Scout Dashboard.
#### What's the difference between task traces and worker metrics?
**Task traces** show individual task execution with timing and attributes. Use
traces to debug specific task failures or performance issues.
**Worker metrics** provide aggregated statistics (queue depth, task rate, worker
utilization). Use metrics for monitoring overall system health and capacity
planning.
#### How do I handle multi-tenant Celery applications?
Add tenant context to spans:
```python
span.set_attributes({
"tenant.id": tenant_id,
"tenant.name": tenant_name,
})
```
Filter traces by tenant in Scout Dashboard.
#### Can I disable instrumentation for specific tasks?
Use the `@celery.task` decorator options or check task name in hooks:
```python
@celery.task(typing=False) # Disable type checking, not instrumentation
# Or filter in custom hook
def task_hook(span, task_id, args, kwargs):
if "health_check" in span.name:
span.set_attribute("otel.ignore", True)
```
### What's Next?
Now that your Celery application is instrumented with OpenTelemetry, explore
these resources:
#### Advanced Topics
- **[Custom Python Instrumentation](../custom-instrumentation/python.md)** -
Deep dive into manual tracing and advanced patterns
- **[FastAPI Instrumentation](./fast-api.md)** - Instrument your API layer for
complete request-to-task tracing
- **[Redis Monitoring](../../component/redis.md)** - Monitor Celery result
backend performance
#### Scout Platform Features
- **[Creating Alerts](../../../guides/creating-alerts-with-logx.md)** - Set up
alerts for task failures, queue depth, and latency thresholds
- **[Dashboard Creation](../../../guides/create-your-first-dashboard.md)** -
Build custom dashboards for Celery task monitoring
#### Deployment and Operations
- **[Docker Compose Setup](../../collector-setup/docker-compose-example.md)** -
Set up Scout Collector for local development
- **[Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md)** -
Production Kubernetes deployment
### Complete Example
Here's a complete working example of a FastAPI + Celery application with
OpenTelemetry instrumentation:
#### Project Structure
```plaintext
celery-demo/
├── celery_demo/
│ ├── __init__.py
│ ├── config.py
│ ├── main.py
│ ├── tasks.py
│ └── telemetry.py
├── config/
│ └── otelcol-config.yaml
├── compose.yaml
├── Dockerfile
├── pyproject.toml
└── .env
```
#### Dependencies
```toml showLineNumbers title="pyproject.toml"
[project]
name = "celery-demo"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.124.0",
"uvicorn[standard]>=0.38.0",
"celery>=5.4.0",
"redis>=5.0.0",
"sqlalchemy>=2.0.0",
"opentelemetry-distro>=0.48b0",
"opentelemetry-exporter-otlp>=1.27.0",
"opentelemetry-instrumentation-celery>=0.48b0",
"opentelemetry-instrumentation-fastapi>=0.48b0",
"opentelemetry-instrumentation-sqlalchemy>=0.48b0",
"opentelemetry-instrumentation-redis>=0.48b0",
"opentelemetry-instrumentation-logging>=0.48b0",
]
```
#### Telemetry Setup
```python showLineNumbers title="celery_demo/telemetry.py"
import logging
import os
from celery.signals import worker_process_init
from opentelemetry import trace, metrics
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.celery import CeleryInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.resource import ResourceAttributes
logger = logging.getLogger(__name__)
OTEL_SERVICE_NAME = os.getenv("OTEL_SERVICE_NAME", "celery-demo")
OTEL_ENDPOINT = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
@worker_process_init.connect(weak=False)
def init_celery_tracing(*args, **kwargs):
"""Initialize tracing for Celery worker processes."""
logger.info("Initializing OpenTelemetry for Celery worker")
init_telemetry()
CeleryInstrumentor().instrument()
def init_telemetry():
"""Initialize OpenTelemetry tracing and metrics."""
resource = Resource(attributes={
ResourceAttributes.SERVICE_NAME: OTEL_SERVICE_NAME,
ResourceAttributes.SERVICE_VERSION: "1.0.0",
})
# Setup trace provider
trace.set_tracer_provider(TracerProvider(resource=resource))
tracer_provider = trace.get_tracer_provider()
span_exporter = OTLPSpanExporter(endpoint=f"{OTEL_ENDPOINT}/v1/traces")
span_processor = BatchSpanProcessor(span_exporter)
tracer_provider.add_span_processor(span_processor)
# Enable logging instrumentation
LoggingInstrumentor().instrument(set_logging_format=True)
# Setup metrics provider
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=f"{OTEL_ENDPOINT}/v1/metrics")
)
metrics.set_meter_provider(
MeterProvider(resource=resource, metric_readers=[metric_reader])
)
logger.info(f"OpenTelemetry initialized for service: {OTEL_SERVICE_NAME}")
def setup_telemetry(app, engine):
"""Configure auto-instrumentation for all components."""
init_telemetry()
FastAPIInstrumentor.instrument_app(app)
SQLAlchemyInstrumentor().instrument(engine=engine)
CeleryInstrumentor().instrument()
RedisInstrumentor().instrument()
logger.info("OpenTelemetry auto-instrumentation setup complete")
```
#### FastAPI Application
```python showLineNumbers title="celery_demo/main.py"
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
from opentelemetry.propagate import inject
from . import models, tasks
from .database import SessionLocal, engine
from .telemetry import setup_telemetry
models.Base.metadata.create_all(bind=engine)
app = FastAPI()
setup_telemetry(app, engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.get("/ping")
async def ping():
return {"message": "pong"}
@app.post("/tasks/")
def create_task(task_data: dict, db: Session = Depends(get_db)):
db_task = models.Task(title=task_data.get("title"))
db.add(db_task)
db.commit()
db.refresh(db_task)
# Propagate trace context to Celery
headers = {}
inject(headers)
tasks.process_task.apply_async(args=[db_task.id], headers=headers)
return {"task_id": db_task.id, "status": "queued"}
```
#### Celery Tasks
```python showLineNumbers title="celery_demo/tasks.py"
from celery import Celery
from opentelemetry import trace
import os
import time
import logging
logger = logging.getLogger(__name__)
RABBITMQ_HOST = os.getenv("RABBITMQ_HOST", "rabbitmq")
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
celery = Celery(
"tasks",
broker=f"amqp://guest:guest@{RABBITMQ_HOST}//",
backend=f"redis://{REDIS_HOST}:6379/0",
)
@celery.task
def process_task(task_id: int):
logger.info(f"Starting to process task {task_id}")
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("process_task") as span:
span.set_attribute("task.id", task_id)
with tracer.start_span("heavy_processing") as processing_span:
time.sleep(2) # Simulate processing
processing_span.set_attribute("processing.duration_ms", 2000)
span.set_attribute("task.status", "completed")
logger.info(f"Task {task_id} completed successfully")
return {"task_id": task_id, "status": "completed"}
```
#### Environment Variables
```bash showLineNumbers title=".env"
OTEL_SERVICE_NAME=celery-demo
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true
```
This complete example is available in our
[GitHub examples repository](https://github.com/base-14/examples/tree/main/scout-collector/docker/celery-demo).
With instrumentation in place, you can
[trace distributed Celery tasks in Scout](https://base14.io/scout/traces)
— follow task execution across workers and brokers with full context
propagation.
### References
- [Official OpenTelemetry Celery Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/celery/celery.html)
- [OpenTelemetry Python Documentation](https://opentelemetry.io/docs/languages/python/)
- [Celery Documentation](https://docs.celeryq.dev/)
### Related Guides
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) - Set
up collector for local development
- [Python Custom Instrumentation](../custom-instrumentation/python.md) - Manual
spans and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
---
## Django OpenTelemetry Instrumentation - ORM, Celery & DRF Tracing
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Introduction
Django is a high-level Python web framework that encourages rapid development
and clean, pragmatic design. As one of the most popular web frameworks for
building data-driven applications, Django applications often handle complex
business logic, database interactions, and background tasks—making observability
critical for maintaining performance and reliability at scale.
Django is a batteries-included Python framework. For lighter or async-first
options see [Flask](./flask.md), [FastAPI](./fast-api.md), and
[Litestar](./litestar.md).
This guide demonstrates how to instrument Django applications with OpenTelemetry
for comprehensive distributed tracing, metrics collection, and application
performance monitoring. We'll cover automatic instrumentation of Django's ORM,
views, middleware, template rendering, and Celery background tasks, providing
visibility into every layer of your application stack.
Django's "batteries-included" philosophy extends to observability through
OpenTelemetry's automatic instrumentation libraries. Unlike manual
instrumentation approaches, Django applications can achieve comprehensive
tracing with minimal code changes—automatically capturing database queries,
HTTP requests, cache operations, and asynchronous task execution. We'll explore
both automatic and custom instrumentation patterns, including Django-specific
considerations like N+1 query detection, PII masking for GDPR compliance, and
management command tracing.
:::tip TL;DR
Install `opentelemetry-instrumentation-django` and
`opentelemetry-instrumentation-psycopg`, then initialize tracing in
`manage.py` or your WSGI/ASGI entry point using
`DjangoInstrumentor().instrument()`. Django's ORM queries, views, middleware,
and Celery tasks are traced automatically with no per-view code changes
required.
:::
### Who This Guide Is For
This guide is designed for:
- **Django Developers** building web applications with complex ORM queries and
needing visibility into database performance and N+1 query issues
- **Backend Engineers** working with Django REST Framework APIs and requiring
end-to-end request tracing across services
- **DevOps Teams** deploying Django applications with Celery workers and needing
distributed tracing across synchronous and asynchronous tasks
- **Data Platform Engineers** managing Django-powered data applications with
PostgreSQL and requiring query-level performance insights
- **Technical Leads** implementing observability standards across Django
microservices and ensuring PII compliance in telemetry data
### Overview
This guide covers Django OpenTelemetry instrumentation using the official
OpenTelemetry Python SDK and Django-specific auto-instrumentation packages. The
approach leverages Django's middleware system and signal framework for
comprehensive, low-overhead tracing.
#### What You'll Learn
- Installing and configuring OpenTelemetry SDK for Django with automatic
instrumentation
- Instrumenting Django ORM queries with full SQL visibility and parameter
binding
- Tracing Django views, middleware, and template rendering automatically
- Setting up Celery distributed tracing for background tasks with context
propagation
- Implementing Django management command instrumentation for batch jobs
- Detecting and resolving N+1 database query issues using span attributes
- Masking PII data in traces for GDPR and HIPAA compliance
- Configuring Django REST Framework for API endpoint tracing
- Optimizing telemetry overhead for high-traffic Django applications
- Deploying instrumented Django apps with Gunicorn, uWSGI, or ASGI servers
#### Prerequisites
**System Requirements:**
- **Python:** 3.9+ (3.13+ recommended for latest features)
- **Django:** 3.2+ (5.0+ LTS recommended)
- **PostgreSQL:** 12+ (18+ recommended) or other supported databases
- **Celery:** 5.0+ for background task tracing (optional)
- **Redis:** 6.0+ for caching and Celery broker (optional)
**Supported Django Versions:**
| Django Version | Python Version | OpenTelemetry Support | Status |
| -------------- | -------------- | --------------------- | ----------- |
| 5.2 LTS | 3.10+ | ✅ Full | Recommended |
| 5.1 | 3.10+ | ✅ Full | Current |
| 5.0 LTS | 3.10+ | ✅ Full | Supported |
| 4.2 LTS | 3.8+ | ✅ Full | Supported |
| 4.1 | 3.8+ | ✅ Full | Legacy |
| 3.2 LTS | 3.6+ | ⚠️ Limited | EOL (2024) |
| 2.x | 3.5+ | ❌ Not supported | EOL |
**Instrumented Components:**
OpenTelemetry Django instrumentation automatically traces:
- ✅ **HTTP Requests** - Django views (function-based and class-based)
- ✅ **Database Queries** - Django ORM queries with SQL and parameters
- ✅ **Template Rendering** - Django template engine execution
- ✅ **Middleware** - All middleware in the processing chain
- ✅ **Cache Operations** - Django cache framework (Redis, Memcached)
- ✅ **Celery Tasks** - Background task execution with distributed context
- ✅ **Management Commands** - Custom Django management commands
- ✅ **Django REST Framework** - API endpoints and serializers
- ✅ **Authentication** - Login, logout, and permission checks
- ✅ **Signals** - Django signal dispatching (with custom instrumentation)
:::info Example Application
This guide references the
[django-postgres example](https://github.com/base-14/examples/tree/main/python/django-postgres)
featuring:
- **Framework**: Django 5.2 LTS with Django REST Framework
- **Database**: PostgreSQL 18 with Django ORM
- **Background Jobs**: Celery 5.4+ with Redis broker
- **Features**: PII masking, N+1 query detection, JWT authentication
- **Deployment**: Gunicorn WSGI server with Docker and Kubernetes
:::
### Installation & Setup
Django OpenTelemetry instrumentation requires the core SDK and Django-specific
auto-instrumentation packages. The setup process installs dependencies and
initializes tracing in your Django application's startup code.
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
**pip (Recommended)**
Install OpenTelemetry SDK and Django instrumentation:
```bash title="Terminal" showLineNumbers
# Install core OpenTelemetry SDK
pip install opentelemetry-api opentelemetry-sdk
# Install Django auto-instrumentation
pip install opentelemetry-instrumentation-django
# Install database instrumentation (PostgreSQL)
pip install opentelemetry-instrumentation-psycopg
# Install Celery instrumentation (optional)
pip install opentelemetry-instrumentation-celery
# Install Redis instrumentation (optional)
pip install opentelemetry-instrumentation-redis
# Install OTLP exporter (send traces to collector)
pip install opentelemetry-exporter-otlp
# Freeze dependencies
pip freeze > requirements.txt
```
**Poetry**
Add dependencies to `pyproject.toml`:
```toml title="pyproject.toml" showLineNumbers
[tool.poetry.dependencies]
python = "^3.10"
django = "^5.2"
psycopg = {extras = ["binary"], version = "^3.3"}
celery = "^5.4"
redis = "^5.0"
# OpenTelemetry dependencies
opentelemetry-api = "^1.41"
opentelemetry-sdk = "^1.41"
opentelemetry-instrumentation-django = "^0.62b0"
opentelemetry-instrumentation-psycopg = "^0.62b0"
opentelemetry-instrumentation-celery = "^0.62b0"
opentelemetry-instrumentation-redis = "^0.62b0"
opentelemetry-exporter-otlp = "^1.41"
```
Install dependencies:
```bash
poetry install
```
**Pipenv**
Add to `Pipfile`:
```toml title="Pipfile" showLineNumbers
[packages]
django = "~=5.2"
psycopg = {extras = ["binary"], version = "~=3.3"}
celery = "~=5.4"
redis = "~=5.0"
opentelemetry-api = "~=1.41"
opentelemetry-sdk = "~=1.41"
opentelemetry-instrumentation-django = "~=0.62b0"
opentelemetry-instrumentation-psycopg = "~=0.62b0"
opentelemetry-instrumentation-celery = "~=0.62b0"
opentelemetry-instrumentation-redis = "~=0.62b0"
opentelemetry-exporter-otlp = "~=1.41"
```
Install:
```bash
pipenv install
```
**Auto-Instrumentation Bootstrap**
Use the OpenTelemetry bootstrap command to automatically install all
instrumentation packages:
```bash title="Terminal" showLineNumbers
# Install bootstrap tool
pip install opentelemetry-bootstrap
# Auto-detect and install instrumentation for installed packages
opentelemetry-bootstrap -a install
# This automatically installs:
# - opentelemetry-instrumentation-django
# - opentelemetry-instrumentation-psycopg
# - opentelemetry-instrumentation-celery
# - opentelemetry-instrumentation-redis
# (based on your installed packages)
```
**Note:** This approach is convenient but installs all detected instrumentations.
For production, explicitly specify dependencies in `requirements.txt`.
:::tip Django Auto-Instrumentation
Django's auto-instrumentation uses middleware injection to capture all HTTP
requests automatically. Unlike Flask, you don't need to manually initialize the
instrumentor in most cases—adding the middleware is sufficient.
:::
### Configuration
Django OpenTelemetry configuration involves initializing the SDK in your
application startup and configuring middleware to capture HTTP requests.
#### Basic Initialization
Create a tracing initialization module:
```python title="myproject/tracing.py" showLineNumbers
"""OpenTelemetry tracing initialization for Django."""
import os
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.django import DjangoInstrumentor
from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor
def initialize_tracing():
"""Initialize OpenTelemetry tracing for Django application."""
# Create resource with service information
resource = Resource.create({
"service.name": os.getenv("OTEL_SERVICE_NAME", "django-order-service"),
"service.version": os.getenv("APP_VERSION", "1.0.0"),
"deployment.environment.name": os.getenv("ENVIRONMENT", "development"),
"environment": os.getenv("ENVIRONMENT", "development"),
})
# Create tracer provider
provider = TracerProvider(resource=resource)
# Configure OTLP exporter
otlp_exporter = OTLPSpanExporter(
endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
insecure=os.getenv("OTEL_EXPORTER_OTLP_INSECURE", "true") == "true",
)
# Add batch span processor
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
# Set as global tracer provider
trace.set_tracer_provider(provider)
# Instrument Django automatically
DjangoInstrumentor().instrument()
# Instrument PostgreSQL
PsycopgInstrumentor().instrument()
print("OpenTelemetry tracing initialized successfully")
```
#### Django Settings Configuration
Update your `settings.py`:
```python title="myproject/settings.py" showLineNumbers
# Django settings for instrumented application
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-secret-key-change-in-production")
DEBUG = os.getenv("DEBUG", "True") == "True"
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'orders', # Your app
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'myproject.urls'
# Database configuration
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.getenv('DB_NAME', 'orders'),
'USER': os.getenv('DB_USER', 'postgres'),
'PASSWORD': os.getenv('DB_PASSWORD', 'postgres'),
'HOST': os.getenv('DB_HOST', 'localhost'),
'PORT': os.getenv('DB_PORT', '5432'),
}
}
# Celery Configuration (optional)
CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL', 'redis://localhost:6379/0')
CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0')
# OpenTelemetry Configuration
OTEL_SERVICE_NAME = os.getenv('OTEL_SERVICE_NAME', 'django-order-service')
OTEL_EXPORTER_OTLP_ENDPOINT = os.getenv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4317')
```
#### Initialize in WSGI/ASGI Application
Update your WSGI or ASGI application file:
```python title="myproject/wsgi.py" showLineNumbers
"""
WSGI config for myproject.
Initializes OpenTelemetry tracing before application starts.
"""
import os
from django.core.wsgi import get_wsgi_application
# Set Django settings module
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
# Initialize OpenTelemetry tracing BEFORE creating WSGI application
from myproject.tracing import initialize_tracing
initialize_tracing()
# Create WSGI application
application = get_wsgi_application()
```
For ASGI (Django Channels):
```python title="myproject/asgi.py" showLineNumbers
"""
ASGI config for myproject.
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
# Initialize tracing before ASGI application
from myproject.tracing import initialize_tracing
initialize_tracing()
application = get_asgi_application()
```
#### Environment Variables
Configure tracing via environment variables:
```bash title=".env" showLineNumbers
# Django configuration
DJANGO_SECRET_KEY=your-secret-key-here
DEBUG=False
ALLOWED_HOSTS=localhost,api.example.com
ENVIRONMENT=development
# Database
DB_NAME=orders
DB_USER=django
DB_PASSWORD=secure-password
DB_HOST=postgres
DB_PORT=5432
# OpenTelemetry configuration
OTEL_SERVICE_NAME=django-order-service
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
OTEL_EXPORTER_OTLP_INSECURE=true
OTEL_SEMCONV_STABILITY_OPT_IN=http,database
APP_VERSION=1.0.0
# Celery (optional)
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
```
`OTEL_SEMCONV_STABILITY_OPT_IN=http,database` opts the instrumentation into the
stable HTTP and database semantic conventions (for example
`http.request.method`, `http.response.status_code`, and `db.query.text`).
Without it, the instrumentation keeps emitting the older experimental attribute
names (`http.method`, `http.status_code`, `db.statement`). Use `http/dup` and
`database/dup` instead to emit both old and new during a migration.
#### Docker Compose Configuration
```yaml title="docker-compose.yml" showLineNumbers
version: '3.9'
services:
django-app:
build: .
command: gunicorn myproject.wsgi:application --bind 0.0.0.0:8000 --workers 4
ports:
- '8000:8000'
environment:
OTEL_SERVICE_NAME: django-order-service
OTEL_EXPORTER_OTLP_ENDPOINT: http://scout-collector:4317
OTEL_EXPORTER_OTLP_INSECURE: 'true'
DB_HOST: postgres
DB_NAME: orders
DB_USER: django
DB_PASSWORD: django123
CELERY_BROKER_URL: redis://redis:6379/0
ENVIRONMENT: docker
depends_on:
- postgres
- redis
- scout-collector
celery-worker:
build: .
command: celery -A myproject worker --loglevel=info
environment:
OTEL_SERVICE_NAME: django-celery-worker
OTEL_EXPORTER_OTLP_ENDPOINT: http://scout-collector:4317
DB_HOST: postgres
CELERY_BROKER_URL: redis://redis:6379/0
depends_on:
- postgres
- redis
- scout-collector
postgres:
image: postgres:18-alpine
environment:
POSTGRES_DB: orders
POSTGRES_USER: django
POSTGRES_PASSWORD: django123
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- '6379:6379'
scout-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ['--config=/etc/otel-collector-config.yaml']
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- '4317:4317'
volumes:
postgres_data:
```
:::info Scout Integration
When using [Base14 Scout](https://base14.io/scout), configure the OTLP endpoint
to point to your Scout Collector with authentication headers. Scout provides
managed infrastructure optimized for Django applications with high query volume.
:::
### Traces
Traces follow a request through your Django application, from the incoming URL
route, through middleware and the view, into ORM queries, cache lookups,
outbound HTTP calls, and Celery task dispatch, and back out as the response.
#### Automatic Trace Collection
Once `DjangoInstrumentor` is applied, every request is traced with no per-view
code:
**Captured Information:**
- HTTP method, resolved URL pattern, and status code for each view
- Request duration and a span-by-span timing breakdown
- Django ORM queries (psycopg / MySQL), including the executed SQL
- Redis cache operations (with `RedisInstrumentor`)
- Celery task enqueue and execution spans (with `CeleryInstrumentor`)
- Exceptions recorded on the failing span with stack traces
- Distributed context propagation across services (W3C Trace Context)
**Trace Hierarchy:**
```text
HTTP Request Span (root: GET /api/orders/)
├── Middleware Span (AuthenticationMiddleware)
├── OrderViewSet.retrieve Span
│ ├── ORM Query Span (SELECT ... FROM orders)
│ └── Redis GET Span (cache lookup)
└── Celery Enqueue Span (send_receipt task)
```
#### Key Tracing Features
- **Automatic HTTP tracking**: every URL route is traced with no code changes
- **ORM visibility**: Django ORM and raw SQL queries appear as child spans with
the executed statement
- **CBV and DRF support**: function views, class-based views, and DRF ViewSets
are all traced with their resolved route
- **Error capturing**: unhandled exceptions and error handlers are recorded with
full stack traces
- **Context propagation**: distributed traces follow requests across HTTP and
Celery boundaries
> View traces in your base14 Scout dashboard to follow request flows and find
> the slow span in a chain.
##### Reference
[Official Traces Documentation](https://opentelemetry.io/docs/concepts/signals/traces/)
### Metrics
Metrics aggregate runtime measurements over time, such as request rate, latency
distributions, and error counts. Where traces explain a single request, metrics
power dashboards and alerts across all of them.
#### Enable the Meter Provider
Configure a `MeterProvider` with an OTLP exporter alongside your tracer setup so
metrics are exported to Scout:
```python title="otel_config.py" showLineNumbers
import os
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
OTLPMetricExporter,
)
# Same OTLP endpoint and gRPC transport as the tracer setup above
resource = Resource.create(
{"service.name": os.getenv("OTEL_SERVICE_NAME", "django-order-service")}
)
reader = PeriodicExportingMetricReader(
OTLPMetricExporter(
endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
insecure=os.getenv("OTEL_EXPORTER_OTLP_INSECURE", "true") == "true",
),
export_interval_millis=15000,
)
metrics.set_meter_provider(MeterProvider(resource=resource, metric_readers=[reader]))
```
#### Custom Business Metrics
Django auto-instrumentation already emits the standard HTTP server metrics,
including the `http.server.request.duration` histogram (its sample count gives
you request rate, latency percentiles, and error ratio per route), so there is
no need to hand-roll request latency. Reserve custom metrics for business
events the instrumentation cannot see, such as domain actions:
```python title="apps/articles/views.py" showLineNumbers
from opentelemetry import metrics
meter = metrics.get_meter("django-app")
articles_created = meter.create_counter(
"articles.created",
unit="1",
description="Articles created",
)
# Inside the view, after the article is persisted:
articles_created.add(1, {"author_id": str(user_id)})
```
> View metrics in your base14 Scout dashboard to chart request rate, latency
> percentiles, and error ratio per route from the automatic HTTP histogram,
> alongside your custom business counters.
##### Reference
[Official Metrics Documentation](https://opentelemetry.io/docs/concepts/signals/metrics/)
### Production Configuration
Production deployments require optimized sampling, secure credential management,
and performance tuning for high-traffic Django applications.
#### Production Tracing Initialization
```python title="myproject/tracing.py" showLineNumbers
"""Production-optimized OpenTelemetry tracing configuration."""
import os
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased, ParentBased
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.django import DjangoInstrumentor
from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
def initialize_tracing():
"""Initialize production-grade OpenTelemetry tracing."""
# Create resource with comprehensive service information
resource = Resource.create({
"service.name": os.getenv("OTEL_SERVICE_NAME", "django-order-service"),
"service.version": os.getenv("APP_VERSION", "1.0.0"),
"deployment.environment.name": os.getenv("ENVIRONMENT", "development"),
"environment": os.getenv("ENVIRONMENT", "development"),
"cloud.provider": os.getenv("CLOUD_PROVIDER", "aws"),
"cloud.region": os.getenv("AWS_REGION", "us-east-1"),
"k8s.cluster.name": os.getenv("K8S_CLUSTER", "production"),
"k8s.namespace.name": os.getenv("K8S_NAMESPACE", "default"),
"k8s.pod.name": os.getenv("HOSTNAME", "unknown"),
})
# Configure sampling (10% of traces in production)
sample_rate = float(os.getenv("OTEL_TRACE_SAMPLE_RATE", "0.1"))
sampler = ParentBased(root=TraceIdRatioBased(sample_rate))
# Create tracer provider with sampler
provider = TracerProvider(resource=resource, sampler=sampler)
# Configure OTLP exporter with authentication
otlp_exporter = OTLPSpanExporter(
endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://scout.base14.io:4317"),
headers={
"authorization": f"Bearer {os.getenv('SCOUT_API_KEY', '')}",
},
insecure=False,
)
# Configure batch span processor with production settings
batch_processor = BatchSpanProcessor(
otlp_exporter,
max_queue_size=2048,
max_export_batch_size=512,
schedule_delay_millis=5000,
export_timeout_millis=30000,
)
provider.add_span_processor(batch_processor)
trace.set_tracer_provider(provider)
# Instrument Django with exclude patterns
DjangoInstrumentor().instrument(
excluded_urls="health/,readiness/,liveness/,metrics/,favicon.ico"
)
# Instrument database
PsycopgInstrumentor().instrument(enable_commenter=True, commenter_options={})
# Instrument Redis
RedisInstrumentor().instrument()
print(f"OpenTelemetry initialized: {resource.attributes.get('service.name')} "
f"v{resource.attributes.get('service.version')} "
f"(sample rate: {sample_rate})")
```
#### Gunicorn Production Configuration
```python title="gunicorn.conf.py" showLineNumbers
"""Gunicorn configuration for production deployment."""
import multiprocessing
import os
# Server socket
bind = "0.0.0.0:8000"
backlog = 2048
# Worker processes
workers = int(os.getenv("GUNICORN_WORKERS", multiprocessing.cpu_count() * 2 + 1))
worker_class = "sync"
worker_connections = 1000
max_requests = 1000
max_requests_jitter = 50
timeout = 30
keepalive = 5
# Logging
accesslog = "-"
errorlog = "-"
loglevel = os.getenv("LOG_LEVEL", "info")
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" trace_id=%(L)s'
# Process naming
proc_name = "django-order-service"
# Server hooks for tracing initialization
def on_starting(server):
"""Initialize OpenTelemetry before workers start."""
from myproject.tracing import initialize_tracing
initialize_tracing()
def post_worker_init(worker):
"""Log worker initialization."""
print(f"Worker {worker.pid} initialized with OpenTelemetry tracing")
```
#### Dockerfile (Multi-Stage Build)
```dockerfile title="Dockerfile" showLineNumbers
# Stage 1: Build dependencies
FROM python:3.13-slim AS builder
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
# Stage 2: Runtime image
FROM python:3.13-slim
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Copy wheels from builder
COPY --from=builder /app/wheels /wheels
COPY --from=builder /app/requirements.txt .
# Install Python packages
RUN pip install --no-cache /wheels/*
# Copy application code
COPY . .
# Create non-root user
RUN useradd -m -u 1000 django && chown -R django:django /app
USER django
# Environment variables
ENV PYTHONUNBUFFERED=1
ENV DJANGO_SETTINGS_MODULE=myproject.settings
ENV OTEL_PYTHON_DJANGO_INSTRUMENT=true
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health/')"
# Run Gunicorn
CMD ["gunicorn", "myproject.wsgi:application", "--config", "gunicorn.conf.py"]
```
#### Kubernetes Deployment
```yaml title="k8s/deployment.yaml" showLineNumbers
apiVersion: apps/v1
kind: Deployment
metadata:
name: django-order-service
labels:
app: django-order-service
spec:
replicas: 3
selector:
matchLabels:
app: django-order-service
template:
metadata:
labels:
app: django-order-service
annotations:
prometheus.io/scrape: 'true'
prometheus.io/port: '8000'
spec:
containers:
- name: django-app
image: django-order-service:latest
ports:
- containerPort: 8000
name: http
env:
- name: OTEL_SERVICE_NAME
value: django-order-service
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://scout-collector:4317
- name: SCOUT_API_KEY
valueFrom:
secretKeyRef:
name: scout-credentials
key: api-key
- name: OTEL_TRACE_SAMPLE_RATE
value: '0.1'
- name: ENVIRONMENT
value: demo
- name: APP_VERSION
value: '1.0.0'
- name: K8S_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: HOSTNAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: DB_HOST
value: postgres-service
- name: DB_NAME
value: orders
- name: DB_USER
valueFrom:
secretKeyRef:
name: postgres-credentials
key: username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-credentials
key: password
- name: CELERY_BROKER_URL
value: redis://redis-service:6379/0
resources:
requests:
memory: '512Mi'
cpu: '250m'
limits:
memory: '1Gi'
cpu: '1000m'
livenessProbe:
httpGet:
path: /health/
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /readiness/
port: 8000
initialDelaySeconds: 10
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: django-order-service
spec:
selector:
app: django-order-service
ports:
- protocol: TCP
port: 80
targetPort: 8000
type: ClusterIP
```
### Django-Specific Features
Django's auto-instrumentation automatically captures views, ORM queries,
middleware, and template rendering without manual span creation.
#### View Auto-Instrumentation
All Django views are automatically instrumented:
```python title="orders/views.py" showLineNumbers
"""Django views with automatic tracing."""
from django.shortcuts import render, get_object_or_404
from django.http import JsonResponse
from django.views import View
from django.views.decorators.http import require_http_methods
from rest_framework import viewsets
from rest_framework.decorators import api_view
from .models import Order
from .serializers import OrderSerializer
# Function-based view (automatically traced)
@require_http_methods(["GET"])
def order_list(request):
"""Get all orders - span name: 'GET /orders/'"""
orders = Order.objects.all().select_related('user')
data = [{"id": o.id, "product": o.product_name, "amount": str(o.amount)} for o in orders]
return JsonResponse({"orders": data})
# Function-based view with parameter
def order_detail(request, order_id):
"""Get order by ID - span name: 'GET /orders/{order_id}/'"""
order = get_object_or_404(Order, id=order_id)
return JsonResponse({
"id": order.id,
"product": order.product_name,
"amount": str(order.amount),
"status": order.status
})
# Class-based view (automatically traced)
class OrderCreateView(View):
"""Create order - span name: 'POST /orders/create/'"""
def post(self, request):
# Business logic automatically traced
order = Order.objects.create(
user_id=request.user.id,
product_name=request.POST.get('product'),
amount=request.POST.get('amount'),
status='pending'
)
return JsonResponse({"id": order.id, "status": "created"}, status=201)
# Django REST Framework ViewSet (automatically traced)
class OrderViewSet(viewsets.ModelViewSet):
"""
API endpoint for orders.
Each action creates span: 'GET /api/orders/', 'POST /api/orders/', etc.
"""
queryset = Order.objects.all()
serializer_class = OrderSerializer
def get_queryset(self):
# Query optimization automatically traced
queryset = super().get_queryset()
return queryset.select_related('user').prefetch_related('items')
def perform_create(self, serializer):
# Custom logic traced as part of the request span
order = serializer.save()
# Trigger async task (traced separately)
from .tasks import process_order
process_order.delay(order.id)
```
#### Django ORM Query Instrumentation
Database queries are automatically traced with full SQL visibility:
```python title="orders/models.py" showLineNumbers
"""Django models with automatic query tracing."""
from django.db import models
from django.contrib.auth.models import User
from decimal import Decimal
class Order(models.Model):
"""Order model - all queries automatically traced."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='orders')
product_name = models.CharField(max_length=200)
amount = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(max_length=50, default='pending')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'orders'
indexes = [
models.Index(fields=['user', 'status']),
models.Index(fields=['created_at']),
]
def __str__(self):
return f"Order {self.id} - {self.product_name}"
# Custom manager methods are traced
@classmethod
def get_user_orders(cls, user_id):
"""Get orders for user - traced as SELECT query with JOIN."""
return cls.objects.filter(user_id=user_id).select_related('user')
@classmethod
def get_recent_orders(cls, limit=10):
"""Get recent orders - traced with LIMIT clause."""
return cls.objects.order_by('-created_at')[:limit]
class OrderItem(models.Model):
"""Order line items."""
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
product_sku = models.CharField(max_length=100)
quantity = models.IntegerField(default=1)
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
class Meta:
db_table = 'order_items'
```
```python title="orders/services.py" showLineNumbers
"""Business logic with ORM query tracing."""
from django.db import transaction
from django.db.models import Sum, Count, Q
from opentelemetry import trace
from .models import Order, OrderItem
tracer = trace.get_tracer(__name__)
class OrderService:
"""Service layer with automatic query tracing."""
@staticmethod
def create_order_with_items(user, items_data):
"""
Create order with items in transaction.
All queries traced automatically with transaction context.
"""
with transaction.atomic():
# INSERT query automatically traced
order = Order.objects.create(
user=user,
product_name=items_data[0]['product'],
amount=sum(item['quantity'] * item['price'] for item in items_data),
status='pending'
)
# Bulk INSERT traced
order_items = [
OrderItem(
order=order,
product_sku=item['sku'],
quantity=item['quantity'],
unit_price=item['price']
)
for item in items_data
]
OrderItem.objects.bulk_create(order_items)
return order
@staticmethod
def get_order_analytics(user_id):
"""
Aggregate query automatically traced.
Span includes: SELECT COUNT(*), SUM(amount), GROUP BY
"""
return Order.objects.filter(user_id=user_id).aggregate(
total_orders=Count('id'),
total_spent=Sum('amount')
)
@staticmethod
def search_orders(query):
"""
Complex query with Q objects - traced with full WHERE clause.
"""
return Order.objects.filter(
Q(product_name__icontains=query) |
Q(status__icontains=query)
).select_related('user').prefetch_related('items')
```
#### Middleware Tracing
All middleware in the processing chain is automatically traced:
```python title="myproject/middleware.py" showLineNumbers
"""Custom middleware with automatic tracing."""
from opentelemetry import trace
from django.utils.deprecation import MiddlewareMixin
tracer = trace.get_tracer(__name__)
class CustomHeaderMiddleware(MiddlewareMixin):
"""
Custom middleware - automatically creates child span.
Span name: 'CustomHeaderMiddleware.process_request'
"""
def process_request(self, request):
# Get current span (created by Django instrumentation)
current_span = trace.get_current_span()
# Add custom attributes
current_span.set_attribute("http.custom_header", request.META.get('HTTP_X_CUSTOM', 'none'))
current_span.set_attribute("request.user.authenticated", request.user.is_authenticated)
if request.user.is_authenticated:
current_span.set_attribute("user.id", str(request.user.id))
current_span.set_attribute("user.username", request.user.username)
return None
def process_response(self, request, response):
# Add response attributes
current_span = trace.get_current_span()
current_span.set_attribute("http.response.body.size", len(response.content))
return response
class PerformanceMonitoringMiddleware(MiddlewareMixin):
"""Monitor slow requests with custom spans."""
def process_view(self, request, view_func, view_args, view_kwargs):
# Create custom span for view execution
with tracer.start_as_current_span("view_execution") as span:
span.set_attribute("view.name", view_func.__name__)
span.set_attribute("view.module", view_func.__module__)
# View execution happens here (automatically traced)
return None
```
#### Template Rendering Tracing
Django template rendering is automatically traced:
```python title="orders/views.py" showLineNumbers
"""Template views with automatic rendering traces."""
from django.shortcuts import render
from .models import Order
def order_list_html(request):
"""
Render template - creates two child spans:
1. SELECT query for Order.objects.all()
2. Template rendering: 'orders/list.html'
"""
orders = Order.objects.all().select_related('user')
# Template rendering automatically traced
return render(request, 'orders/list.html', {
'orders': orders,
'title': 'Order List'
})
def order_detail_html(request, order_id):
"""
Complex template with includes - each template traced separately:
- 'orders/detail.html'
- 'orders/includes/order_summary.html'
- 'orders/includes/order_items.html'
"""
order = Order.objects.get(id=order_id)
return render(request, 'orders/detail.html', {'order': order})
```
#### Celery Task Tracing
Celery tasks are automatically traced with distributed context propagation:
```python title="orders/tasks.py" showLineNumbers
"""Celery tasks with automatic distributed tracing."""
from celery import shared_task
from opentelemetry import trace
from django.core.mail import send_mail
from .models import Order
tracer = trace.get_tracer(__name__)
@shared_task
def process_order(order_id):
"""
Process order asynchronously.
Automatically creates span: 'orders.tasks.process_order'
Trace context propagated from parent request.
"""
# Get current span (linked to original request via trace context)
current_span = trace.get_current_span()
current_span.set_attribute("order.id", order_id)
# Database query traced automatically
order = Order.objects.select_related('user').get(id=order_id)
# Business logic
if order.amount > 1000:
# Call external service (traced if instrumented)
validate_high_value_order(order)
# Update status (UPDATE query traced)
order.status = 'processing'
order.save()
# Send notification (SMTP traced if instrumented)
send_order_confirmation_email(order)
current_span.set_attribute("order.status", "completed")
return {"order_id": order_id, "status": "processed"}
@shared_task
def send_order_confirmation_email(order):
"""Send email - traced as child span."""
with tracer.start_as_current_span("send_confirmation_email") as span:
span.set_attribute("email.to", order.user.email)
span.set_attribute("order.id", order.id)
send_mail(
subject=f'Order Confirmation - {order.id}',
message=f'Your order for {order.product_name} has been confirmed.',
from_email='noreply@example.com',
recipient_list=[order.user.email],
)
def validate_high_value_order(order):
"""Custom validation with manual span."""
with tracer.start_as_current_span("validate_high_value_order") as span:
span.set_attribute("order.id", order.id)
span.set_attribute("order.amount", float(order.amount))
# Complex validation logic
if order.amount > 10000:
span.set_attribute("validation.requires_approval", True)
# Trigger approval workflow
else:
span.set_attribute("validation.auto_approved", True)
```
Initialize Celery with tracing:
```python title="myproject/celery.py" showLineNumbers
"""Celery configuration with OpenTelemetry."""
import os
from celery import Celery
from celery.signals import worker_process_init
# Set Django settings module
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
app = Celery('myproject')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
@worker_process_init.connect
def init_tracing_on_worker(**kwargs):
"""Initialize OpenTelemetry in Celery worker process."""
from myproject.tracing import initialize_tracing
initialize_tracing()
print("OpenTelemetry initialized in Celery worker")
```
### Custom Instrumentation
While Django auto-instrumentation covers most use cases, custom spans are needed
for specific business logic or external integrations.
#### Manual Span Creation
```python title="orders/services.py" showLineNumbers
"""Custom instrumentation for business logic."""
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
from django.db import transaction
from .models import Order
tracer = trace.get_tracer(__name__)
class PaymentService:
"""Payment processing with custom instrumentation."""
@staticmethod
def process_payment(order_id, payment_method):
"""Process payment with custom span."""
with tracer.start_as_current_span("process_payment") as span:
span.set_attribute("order.id", order_id)
span.set_attribute("payment.method", payment_method)
try:
# Get order (automatically traced)
order = Order.objects.get(id=order_id)
span.set_attribute("payment.amount", float(order.amount))
# Validate payment method
PaymentService._validate_payment_method(payment_method)
# Call external payment gateway
transaction_id = PaymentService._charge_payment_gateway(
order.amount,
payment_method
)
span.set_attribute("payment.transaction_id", transaction_id)
span.set_status(Status(StatusCode.OK))
return {"success": True, "transaction_id": transaction_id}
except Exception as e:
span.set_status(Status(StatusCode.ERROR, str(e)))
span.record_exception(e)
raise
@staticmethod
def _validate_payment_method(method):
"""Validation with custom span."""
with tracer.start_as_current_span("validate_payment_method") as span:
span.set_attribute("payment.method", method)
valid_methods = ["credit_card", "debit_card", "paypal"]
if method not in valid_methods:
span.set_status(Status(StatusCode.ERROR, "Invalid payment method"))
raise ValueError(f"Invalid payment method: {method}")
span.set_attribute("validation.result", "valid")
@staticmethod
def _charge_payment_gateway(amount, method):
"""External API call with custom span."""
with tracer.start_as_current_span(
"payment_gateway.charge",
kind=trace.SpanKind.CLIENT
) as span:
span.set_attribute("payment.amount", float(amount))
span.set_attribute("payment.method", method)
span.set_attribute("payment.gateway", "stripe")
# Simulate external API call
import requests
response = requests.post(
"https://api.stripe.com/v1/charges",
json={"amount": float(amount), "method": method},
timeout=10
)
span.set_attribute("http.response.status_code", response.status_code)
if response.status_code == 200:
transaction_id = response.json().get("id")
span.set_attribute("payment.transaction_id", transaction_id)
return transaction_id
else:
span.set_status(Status(StatusCode.ERROR, "Payment failed"))
raise Exception("Payment gateway error")
```
#### Django Management Command Tracing
```python title="orders/management/commands/process_pending_orders.py" showLineNumbers
"""Management command with custom instrumentation."""
from django.core.management.base import BaseCommand
from opentelemetry import trace
from orders.models import Order
from orders.tasks import process_order
tracer = trace.get_tracer(__name__)
class Command(BaseCommand):
help = 'Process all pending orders'
def add_arguments(self, parser):
parser.add_argument('--limit', type=int, default=100, help='Max orders to process')
parser.add_argument('--dry-run', action='store_true', help='Dry run mode')
def handle(self, *args, **options):
"""Execute command with custom tracing."""
with tracer.start_as_current_span("management_command.process_pending_orders") as span:
limit = options['limit']
dry_run = options['dry_run']
span.set_attribute("command.limit", limit)
span.set_attribute("command.dry_run", dry_run)
# Query pending orders (automatically traced)
pending_orders = Order.objects.filter(status='pending')[:limit]
count = pending_orders.count()
span.set_attribute("orders.pending_count", count)
if dry_run:
self.stdout.write(f"[DRY RUN] Would process {count} orders")
span.set_attribute("command.result", "dry_run")
return
# Process each order
processed = 0
failed = 0
for order in pending_orders:
with tracer.start_as_current_span("process_single_order") as order_span:
order_span.set_attribute("order.id", order.id)
try:
# Trigger Celery task (traced separately)
process_order.delay(order.id)
processed += 1
order_span.set_status(trace.Status(trace.StatusCode.OK))
except Exception as e:
failed += 1
order_span.set_status(trace.Status(trace.StatusCode.ERROR, str(e)))
order_span.record_exception(e)
span.set_attribute("orders.processed_count", processed)
span.set_attribute("orders.failed_count", failed)
self.stdout.write(self.style.SUCCESS(
f'Processed {processed} orders, {failed} failed'
))
```
#### N+1 Query Detection
Add custom span attributes to detect N+1 query patterns:
```python title="orders/utils.py" showLineNumbers
"""Utilities for detecting N+1 queries."""
from django.db import connection, reset_queries
from django.conf import settings
from opentelemetry import trace
from functools import wraps
tracer = trace.get_tracer(__name__)
def detect_n_plus_one(func):
"""Decorator to detect N+1 query patterns."""
@wraps(func)
def wrapper(*args, **kwargs):
if not settings.DEBUG:
return func(*args, **kwargs)
reset_queries()
result = func(*args, **kwargs)
# Analyze query patterns
queries = connection.queries
query_count = len(queries)
# Get current span
current_span = trace.get_current_span()
current_span.set_attribute("db.query_count", query_count)
# Detect potential N+1
similar_queries = {}
for query in queries:
sql = query['sql'].split('WHERE')[0] # Group by base query
similar_queries[sql] = similar_queries.get(sql, 0) + 1
# Flag if any query executed multiple times
max_repetitions = max(similar_queries.values()) if similar_queries else 0
if max_repetitions > 5:
current_span.set_attribute("db.potential_n_plus_one", True)
current_span.set_attribute("db.max_query_repetitions", max_repetitions)
return result
return wrapper
```
Usage:
```python title="orders/views.py" showLineNumbers
from orders.utils import detect_n_plus_one
@detect_n_plus_one
def get_all_orders_with_users(request):
"""This will flag N+1 if select_related is missing."""
# BAD: N+1 query (flagged in span attributes)
orders = Order.objects.all()
for order in orders:
print(order.user.username) # Separate query for each order
# GOOD: Optimized query
orders = Order.objects.all().select_related('user')
for order in orders:
print(order.user.username) # No extra queries
return JsonResponse({"count": len(orders)})
```
### Running Your Application
Django applications can run with various WSGI/ASGI servers. OpenTelemetry
instrumentation works with all standard deployment methods.
#### Development Server
```bash title="Terminal" showLineNumbers
# Run Django development server with tracing
python manage.py runserver
# Access application
curl http://localhost:8000/orders/
# Run Celery worker (separate terminal)
celery -A myproject worker --loglevel=info
# Run Celery beat (scheduled tasks)
celery -A myproject beat --loglevel=info
```
#### Gunicorn (Production WSGI)
```bash title="Terminal" showLineNumbers
# Run with Gunicorn
gunicorn myproject.wsgi:application \
--bind 0.0.0.0:8000 \
--workers 4 \
--config gunicorn.conf.py
# With environment variables
OTEL_SERVICE_NAME=django-order-service \
OTEL_EXPORTER_OTLP_ENDPOINT=https://scout.base14.io:4317 \
SCOUT_API_KEY=your_api_key \
gunicorn myproject.wsgi:application --bind 0.0.0.0:8000
```
#### uWSGI (Alternative WSGI)
```ini title="uwsgi.ini" showLineNumbers
[uwsgi]
module = myproject.wsgi:application
master = true
processes = 4
socket = /tmp/myproject.sock
chmod-socket = 666
vacuum = true
die-on-term = true
# OpenTelemetry environment
env = OTEL_SERVICE_NAME=django-order-service
env = OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
```
Run:
```bash
uwsgi --ini uwsgi.ini
```
#### Daphne (ASGI for Django Channels)
```bash title="Terminal" showLineNumbers
# Run with Daphne for async/WebSocket support
daphne -b 0.0.0.0 -p 8000 myproject.asgi:application
# With environment variables
OTEL_SERVICE_NAME=django-channels \
daphne myproject.asgi:application
```
#### Docker Deployment
```bash title="Terminal" showLineNumbers
# Build Docker image
docker build -t django-order-service:latest .
# Run container
docker run -p 8000:8000 \
-e OTEL_SERVICE_NAME=django-order-service \
-e OTEL_EXPORTER_OTLP_ENDPOINT=https://scout.base14.io:4317 \
-e SCOUT_API_KEY=your_api_key \
-e DB_HOST=postgres \
-e DB_NAME=orders \
django-order-service:latest
# Run with Docker Compose
docker-compose up -d
# Check logs
docker-compose logs -f django-app
# Run migrations
docker-compose exec django-app python manage.py migrate
# Create superuser
docker-compose exec django-app python manage.py createsuperuser
```
#### Kubernetes Deployment
```bash title="Terminal" showLineNumbers
# Deploy to Kubernetes
kubectl apply -f k8s/deployment.yaml
# Check pod status
kubectl get pods -l app=django-order-service
# View logs
kubectl logs -f deployment/django-order-service
# Run migrations (one-time job)
kubectl run django-migrate --rm -i --tty \
--image=django-order-service:latest \
--restart=Never \
--command -- python manage.py migrate
# Access application
kubectl port-forward deployment/django-order-service 8000:8000
curl http://localhost:8000/orders/
```
### Troubleshooting
#### Issue 1: No Traces Generated
**Symptoms:** Django application starts but no traces appear in collector.
**Diagnosis:**
```python
# Add debug logging to tracing initialization
import logging
logging.basicConfig(level=logging.DEBUG)
from myproject.tracing import initialize_tracing
initialize_tracing()
```
**Solution:**
Ensure Django instrumentation is initialized before application starts:
```python title="myproject/wsgi.py" showLineNumbers
# INCORRECT: Instrumentation after WSGI app creation
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()
from myproject.tracing import initialize_tracing # TOO LATE!
initialize_tracing()
# CORRECT: Instrumentation before WSGI app
from myproject.tracing import initialize_tracing
initialize_tracing() # Initialize first
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()
```
#### Issue 2: Database Queries Not Traced
**Symptoms:** HTTP requests create spans but SQL queries are missing.
**Solution:**
Instrument the database driver explicitly:
```python title="myproject/tracing.py" showLineNumbers
from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor
# For PostgreSQL with psycopg
PsycopgInstrumentor().instrument(enable_commenter=True)
# For MySQL
from opentelemetry.instrumentation.pymysql import PyMySQLInstrumentor
PyMySQLInstrumentor().instrument()
# For SQLite (Django default DB for development)
from opentelemetry.instrumentation.sqlite3 import SQLite3Instrumentor
SQLite3Instrumentor().instrument()
```
#### Issue 3: Celery Tasks Not Linked to Parent Trace
**Symptoms:** Celery task spans exist but are disconnected from the originating
HTTP request.
**Solution:**
Ensure Celery instrumentation is initialized in worker process:
```python title="myproject/celery.py" showLineNumbers
from celery import Celery
from celery.signals import worker_process_init, worker_process_shutdown
from opentelemetry.instrumentation.celery import CeleryInstrumentor
app = Celery('myproject')
@worker_process_init.connect
def init_celery_tracing(**kwargs):
"""Initialize tracing in each worker process."""
from myproject.tracing import initialize_tracing
initialize_tracing()
# Instrument Celery
CeleryInstrumentor().instrument()
@worker_process_shutdown.connect
def shutdown_tracing(**kwargs):
"""Clean shutdown of tracing."""
from opentelemetry import trace
trace.get_tracer_provider().shutdown()
```
#### Issue 4: High Memory Usage with Tracing
**Symptoms:** Django application memory grows continuously with tracing enabled.
**Solution:**
Configure batch span processor limits:
```python title="myproject/tracing.py" showLineNumbers
from opentelemetry.sdk.trace.export import BatchSpanProcessor
# Reduce memory footprint
batch_processor = BatchSpanProcessor(
otlp_exporter,
max_queue_size=1024, # Reduced from default 2048
max_export_batch_size=256, # Reduced from default 512
schedule_delay_millis=3000, # Export more frequently
)
```
#### Issue 5: Middleware Order Causing Issues
**Symptoms:** Some requests traced, others not, or tracing interferes with
authentication.
**Solution:**
Django's auto-instrumentation injects middleware automatically. Ensure proper
ordering:
```python title="settings.py" showLineNumbers
MIDDLEWARE = [
# OpenTelemetry middleware injected here automatically (first)
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', # After session
# Custom middleware after auth
'myproject.middleware.CustomHeaderMiddleware',
]
```
### Security Considerations
#### PII Data Masking
Django applications often handle sensitive user data. Implement custom span
processors to mask PII:
```python title="myproject/tracing.py" showLineNumbers
"""PII masking for Django tracing."""
import re
from opentelemetry.sdk.trace import SpanProcessor, ReadableSpan
class PIIMaskingSpanProcessor(SpanProcessor):
"""Mask PII data in span attributes."""
EMAIL_PATTERN = re.compile(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}')
PHONE_PATTERN = re.compile(r'\b\d{3}[-.]?\d{3}[-.]?\d{4}\b')
SSN_PATTERN = re.compile(r'\b\d{3}-\d{2}-\d{4}\b')
def on_start(self, span: "ReadableSpan", parent_context=None) -> None:
"""Mask PII in span name and attributes on span start."""
# Get writable span
if hasattr(span, '_name'):
span._name = self._mask_pii(span._name)
def on_end(self, span: ReadableSpan) -> None:
"""Mask PII in final span data."""
if hasattr(span, 'attributes'):
for key, value in span.attributes.items():
if isinstance(value, str):
span.attributes[key] = self._mask_pii(value)
def _mask_pii(self, text: str) -> str:
"""Mask sensitive patterns in text."""
if not isinstance(text, str):
return text
# Mask email addresses
text = self.EMAIL_PATTERN.sub('***@***.**', text)
# Mask phone numbers
text = self.PHONE_PATTERN.sub('***-***-****', text)
# Mask SSN
text = self.SSN_PATTERN.sub('***-**-****', text)
return text
def shutdown(self) -> None:
pass
def force_flush(self, timeout_millis: int = 30000) -> bool:
return True
# Add to tracer provider
def initialize_tracing():
# ... existing setup ...
provider.add_span_processor(PIIMaskingSpanProcessor())
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
```
#### SQL Parameter Obfuscation
Database queries may contain sensitive values. The query text lands on the span
as `db.query.text` only when `OTEL_SEMCONV_STABILITY_OPT_IN` includes `database`
and `opentelemetry-instrumentation-psycopg` is `>=0.62b0`; on older versions, or
without the opt-in, the attribute is named `db.statement` instead.
```python title="myproject/tracing.py" showLineNumbers
"""SQL query sanitization."""
from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor
# Enable SQL commenter to identify queries, but disable parameter logging
PsycopgInstrumentor().instrument(
enable_commenter=True,
commenter_options={
"db_driver": True,
"db_framework": True,
"opentelemetry_values": False, # Don't include parameter values
}
)
```
Custom query sanitization:
```python title="myproject/middleware.py" showLineNumbers
"""Sanitize database queries in spans."""
import re
from opentelemetry import trace
class QuerySanitizationMiddleware:
"""Sanitize SQL queries to remove sensitive data."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Post-process spans to sanitize queries
current_span = trace.get_current_span()
if hasattr(current_span, 'attributes'):
if 'db.query.text' in current_span.attributes:
# Replace parameter values with placeholders
sql = current_span.attributes['db.query.text']
# Remove values: WHERE email = 'user@example.com' -> WHERE email = ?
sanitized = re.sub(r"= '.*?'", "= ?", sql)
current_span.attributes['db.query.text'] = sanitized
return response
```
#### GDPR Compliance
For GDPR compliance, exclude user-identifying information:
```python title="myproject/tracing.py" showLineNumbers
"""GDPR-compliant tracing configuration."""
def initialize_tracing_gdpr_compliant():
"""Initialize tracing without collecting user PII."""
# ... existing setup ...
# Instrument Django with URL exclusions
from opentelemetry.instrumentation.django import DjangoInstrumentor
DjangoInstrumentor().instrument(
# Exclude endpoints that handle PII
excluded_urls="admin/,accounts/profile/,api/users/",
# Disable automatic user context
request_hook=None,
response_hook=None,
)
# Configure span limits to prevent large payloads
from opentelemetry.sdk.trace import TracerProvider
provider = TracerProvider(
resource=resource,
span_limits=SpanLimits(
max_attributes=32,
max_events=32,
max_links=32,
max_attribute_length=256,
),
)
```
### Performance Considerations
#### Tracing Overhead Metrics
Measured performance impact of OpenTelemetry on Django applications:
| Configuration | Latency (p50) | Latency (p99) | Throughput | Memory |
| ------------------ | ------------- | ------------- | ---------- | ------- |
| **No Tracing** | 15ms | 45ms | 8,000 rps | 250MB |
| **Tracing (100%)** | 16ms (+6%) | 50ms (+11%) | 7,500 rps | 320MB |
| **Tracing (10%)** | 15ms (+<1%)| 46ms (+2%) | 7,900 rps | 270MB |
**Key Findings:**
- Sampling at 10% reduces overhead to negligible levels
- ORM query tracing adds ~1ms per complex query
- Template rendering tracing: <0.5ms overhead
- Celery task tracing: <2ms overhead per task
#### Optimization Strategies
##### 1. Exclude High-Volume Endpoints
```python title="myproject/tracing.py" showLineNumbers
from opentelemetry.instrumentation.django import DjangoInstrumentor
# Don't trace health checks, metrics, static files
DjangoInstrumentor().instrument(
excluded_urls="health/,readiness/,metrics/,static/,media/,favicon.ico"
)
```
##### 2. Optimize ORM Queries to Reduce Spans
```python title="orders/views.py" showLineNumbers
# BAD: Creates N+1 query spans
def get_orders_bad(request):
orders = Order.objects.all() # 1 query
for order in orders:
print(order.user.username) # N queries (N spans!)
# GOOD: Single query with JOIN (1 span)
def get_orders_good(request):
orders = Order.objects.select_related('user').all() # 1 query
for order in orders:
print(order.user.username) # No extra queries
```
##### 3. Batch Span Export Configuration
```python title="myproject/tracing.py" showLineNumbers
from opentelemetry.sdk.trace.export import BatchSpanProcessor
# Optimized for high-throughput Django apps
batch_processor = BatchSpanProcessor(
otlp_exporter,
max_queue_size=2048,
max_export_batch_size=512,
schedule_delay_millis=5000, # Export every 5 seconds
export_timeout_millis=30000,
)
```
##### 4. Disable Tracing in Tests
```python title="myproject/settings.py" showLineNumbers
# Disable tracing in test environment
import sys
TESTING = 'test' in sys.argv
if TESTING:
OTEL_SDK_DISABLED = True
```
Or use environment variable:
```bash
OTEL_SDK_DISABLED=true python manage.py test
```
### FAQ
#### Do I need to manually instrument Django views with OpenTelemetry?
**No.** Django's auto-instrumentation automatically traces all views (function-based
and class-based) when you call `DjangoInstrumentor().instrument()`. No decorators
or manual span creation required for basic request/response tracing.
#### How do I trace Django management commands?
Use custom spans in your management command's `handle()` method:
```python
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
class Command(BaseCommand):
def handle(self, *args, **options):
with tracer.start_as_current_span("management_command.my_command"):
# Your command logic here
pass
```
#### Can I use OpenTelemetry with Django REST Framework?
**Yes.** Django REST Framework views and viewsets are automatically instrumented
through Django's middleware. Each API endpoint creates a span with the HTTP
method and path (e.g., `GET /api/orders/`).
#### How do I detect N+1 queries in Django with OpenTelemetry tracing?
Check span attributes for `db.query_count`. High counts (>10 queries per request)
often indicate N+1 issues. Use the custom decorator shown in the
"Custom Instrumentation" section to automatically flag potential N+1 patterns.
#### Does OpenTelemetry tracing work with Celery background tasks in Django?
**Yes.** Install `opentelemetry-instrumentation-celery` and instrument in your
Celery worker initialization. Trace context automatically propagates from Django
views to Celery tasks, creating a distributed trace across synchronous and
asynchronous operations.
#### How do I mask PII data in traces?
Implement a custom `SpanProcessor` (see "Security Considerations" section) that
filters sensitive data patterns (emails, phone numbers, SSNs) from span names
and attributes before export.
#### Can I trace async Django views (ASGI)?
**Yes.** OpenTelemetry supports ASGI applications. Initialize tracing in your
`asgi.py` file before creating the ASGI application, and async views will be
traced automatically.
#### What is the performance overhead of OpenTelemetry tracing in Django?
With 10% sampling, overhead is typically <1% for latency and ~8% for memory.
Without sampling (100% tracing), expect ~6% latency increase and ~30% memory
increase. See "Performance Considerations" for detailed metrics.
#### How do I send traces to Base14 Scout?
Point the OTLP exporter at your Scout endpoint on port 4317 and pass your
API key as a bearer token in the headers:
```python
OTLPSpanExporter(
endpoint="https://scout.base14.io:4317",
headers={"authorization": f"Bearer {os.getenv('SCOUT_API_KEY')}"},
)
```
#### Can I trace template rendering?
**Yes.** Template rendering is automatically traced when using `render()` or
`TemplateResponse`. Each template creates a child span showing rendering time.
#### How do I trace multiple databases?
Django's database instrumentation traces all configured databases automatically.
Span attributes include `db.namespace` to differentiate between databases (with
the `database` semconv opt-in set; older builds emit the deprecated `db.name`).
#### Can I disable tracing for specific views?
Use the `excluded_urls` parameter in `DjangoInstrumentor().instrument()`:
```python
DjangoInstrumentor().instrument(
excluded_urls="admin/,debug/,internal/"
)
```
### What's Next
Now that you have Django instrumented with OpenTelemetry, explore advanced
observability patterns:
#### Advanced Tracing Topics
- **[Custom Instrumentation for Python](/instrument/apps/custom-instrumentation/python)**
\- Deep dive into manual span creation and context propagation
- **[All framework guides](/instrument/apps/auto-instrumentation/)** \-
Auto-instrumentation overview for every language
#### Scout Platform Features
- **[Base14 Scout Dashboard](https://base14.io/scout)** - Visualize Django
traces with ORM query insights and N+1 detection
- **Service Map Visualization** - Understand dependencies between Django,
Celery, and external services
- **Alert Configuration** - Set up alerts for slow database queries and high
error rates
#### Deployment & Operations
- **Docker Instrumentation** - Deploy instrumented Django apps in containers
- **Kubernetes Deployment** - Run Django with sidecar collectors and service mesh
- **AWS Deployment** - Deploy to ECS, Elastic Beanstalk, or Lambda with tracing
### Complete Example
Here's a complete Django application with OpenTelemetry instrumentation,
including ORM queries, views, Celery tasks, and custom business logic.
#### Project Structure
```text
django-order-service/
├── manage.py
├── myproject/
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ ├── wsgi.py
│ ├── asgi.py
│ ├── celery.py
│ └── tracing.py
├── orders/
│ ├── __init__.py
│ ├── models.py
│ ├── views.py
│ ├── serializers.py
│ ├── tasks.py
│ ├── services.py
│ └── management/
│ └── commands/
│ └── process_pending_orders.py
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
└── gunicorn.conf.py
```
#### Complete Settings Configuration
```python title="myproject/settings.py" showLineNumbers
"""Django settings with OpenTelemetry configuration."""
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'dev-secret-change-in-production')
DEBUG = os.getenv('DEBUG', 'False') == 'True'
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost').split(',')
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'orders',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'myproject.urls'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.getenv('DB_NAME', 'orders'),
'USER': os.getenv('DB_USER', 'postgres'),
'PASSWORD': os.getenv('DB_PASSWORD', 'postgres'),
'HOST': os.getenv('DB_HOST', 'localhost'),
'PORT': os.getenv('DB_PORT', '5432'),
}
}
# Celery Configuration
CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL', 'redis://localhost:6379/0')
CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0')
# OpenTelemetry
OTEL_SERVICE_NAME = os.getenv('OTEL_SERVICE_NAME', 'django-order-service')
OTEL_EXPORTER_OTLP_ENDPOINT = os.getenv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4317')
# Static files
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
```
#### Running the Example
```bash title="Terminal" showLineNumbers
# Clone the examples repository
git clone https://github.com/base-14/examples.git
cd examples/python/django-postgres
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Start infrastructure
docker-compose up -d postgres redis scout-collector
# Run migrations
python manage.py migrate
# Create superuser
python manage.py createsuperuser
# Run development server
python manage.py runserver
# In another terminal: Run Celery worker
celery -A myproject worker --loglevel=info
# Test endpoints
# Create order
curl -X POST http://localhost:8000/api/orders/ \
-H "Content-Type: application/json" \
-d '{"product_name":"Widget","amount":"99.99","status":"pending"}'
# Get all orders
curl http://localhost:8000/api/orders/
# Process pending orders (management command)
python manage.py process_pending_orders --limit 10
# View traces in Scout Dashboard
open https://scout.base14.io
```
#### Expected Trace Output
When you create an order via `POST /api/orders/`, you should see:
```text
POST /api/orders/ (250ms)
├── OrderViewSet.create (200ms)
│ ├── SELECT FROM auth_user WHERE id = ? (5ms)
│ ├── INSERT INTO orders (...) VALUES (...) (10ms)
│ ├── celery.apply_async: process_order (5ms)
│ └── serializer.save (15ms)
└── Celery Task: process_order (150ms) [separate trace, linked]
├── SELECT FROM orders WHERE id = ? (5ms)
├── validate_high_value_order (30ms)
├── UPDATE orders SET status = ? WHERE id = ? (8ms)
└── send_confirmation_email (100ms)
```
:::tip Complete Example Repository
The full example application with Docker Compose, Kubernetes manifests,
management commands, and PII masking is available at:
**[https://github.com/base-14/examples/tree/main/python/django-postgres](https://github.com/base-14/examples/tree/main/python/django-postgres)**
This includes production-ready configurations for AWS, GCP, and Azure
deployments.
:::
Once telemetry is flowing, you can
[analyze Django request traces in Scout](https://base14.io/scout/apm)
— monitor view performance, ORM query patterns, and Celery task execution
from a single pane.
### References
#### Official Documentation
- **[Django Documentation](https://docs.djangoproject.com/)** \- Official Django
framework documentation
- **[OpenTelemetry Python SDK](https://opentelemetry.io/docs/languages/python/)**
\- Core OpenTelemetry Python documentation
- **[Django Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/django/django.html)**
\- Official Django auto-instrumentation docs
- **[Celery Documentation](https://docs.celeryproject.org/)** \- Distributed
task queue documentation
#### Tools & Resources
- **[Base14 Scout](https://base14.io/scout)** \- Managed OpenTelemetry platform
for Django applications
- **[Django Debug Toolbar](https://django-debug-toolbar.readthedocs.io/)** \-
Development tool for query analysis
- **[Django REST Framework](https://www.django-rest-framework.org/)** \- API
framework with automatic instrumentation
### Related Guides
- [Flask Instrumentation](/instrument/apps/auto-instrumentation/flask) -
Lightweight Python framework instrumentation
- [FastAPI Instrumentation](/instrument/apps/auto-instrumentation/fast-api) -
Async Python API framework
- [Celery Tracing](/instrument/apps/auto-instrumentation/celery) - Distributed
task queue instrumentation
- [Python Custom Instrumentation](/instrument/apps/custom-instrumentation/python)
\- Manual spans and advanced patterns
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) - Set
up the collector for local development
---
## .NET Aspire OpenTelemetry Instrumentation - Multi-Service Tracing
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
## .NET Aspire
Implement OpenTelemetry instrumentation for .NET Aspire applications to capture
distributed traces, metrics, and structured logs across an orchestrated set of
.NET microservices. This guide shows you how to wire OpenTelemetry through
Aspire's ServiceDefaults convention, point the OTLP exporter at a local OTel
Collector, and forward telemetry to base14 Scout - all without using Azure
Monitor as the destination.
.NET Aspire is an opinionated stack for cloud-native .NET apps. For general .NET
instrumentation, see the [.NET guide](./dotnet.md).
.NET Aspire is Microsoft's first-party orchestration framework for .NET
microservices. It ships with OpenTelemetry support out of the box, opinionated
service-discovery and resilience defaults, and a local dashboard for resource
state and lifecycle inspection. Aspire's value to a Scout customer is that it
standardizes the observability shape across every service in your distributed
application: a single `AddServiceDefaults()` call in each project's
`Program.cs` produces consistent OTel traces, metrics, and logs without
per-service boilerplate.
Whether you are migrating an Aspire app from Application Insights, building
greenfield on Aspire 13, or evaluating Aspire as the orchestration layer
between Scout and your services, this guide covers the production-ready setup:
ServiceDefaults wiring, AppHost resource declaration, the OTLP-endpoint
override knob, and the gotchas around `WithEndpoint` URL serialization,
custom-source registration, and the dashboard's relationship with telemetry
when you target an external destination.
:::tip TL;DR
Reference `Aspire.Hosting.AppHost` 13.2.4 and `Aspire.Hosting.PostgreSQL`
13.2.4 on your AppHost project. Add a `ServiceDefaults` class library that
exposes `AddServiceDefaults(this IHostApplicationBuilder)` and registers
ASP.NET Core, HttpClient, EF Core, and Runtime instrumentation alongside any
custom `ActivitySource`s and `Meter`s. In `AppHost.cs`, declare an OTel
Collector container with `builder.AddContainer(...)` and override
`OTEL_EXPORTER_OTLP_ENDPOINT` on each project to point at the collector's
`grpc` endpoint - declared with `WithHttpEndpoint`, not `WithEndpoint`, so the
exporter receives an `http://` URL it can parse.
:::
> **Note:** This guide is Aspire-specific. For the general ASP.NET Core
> OpenTelemetry setup (including SqlClient and JWT) see the
> [ASP.NET Core guide](./dotnet.md). For the broader Aspire framework
> documentation, see the
> [official .NET Aspire docs](https://learn.microsoft.com/dotnet/aspire/).
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Who This Guide Is For
This documentation is designed for:
- **.NET microservice developers:** building or maintaining Aspire-orchestrated
services and wanting consistent observability across them.
- **Engineering teams:** migrating from Application Insights to OTel plus Scout
and looking for a vendor-neutral export path that works in dev, CI, and
production.
- **DevOps engineers:** running Aspire workloads in CI or headless customer
environments and needing a Compose-mode fallback that does not require the
AppHost.
- **Platform teams:** standardizing on the ServiceDefaults pattern across
multiple Aspire applications and looking for the canonical OpenTelemetry
registration shape.
### Overview
This guide demonstrates how to:
- Configure .NET Aspire 13 AppHost with PostgreSQL, an OTel Collector
container, and two .NET project resources.
- Build a `ServiceDefaults` class library that registers OpenTelemetry,
resilience, service discovery, and health checks for every Aspire-managed
project.
- Wire a custom `ActivitySource` and `Meter` into the OpenTelemetry tracer and
meter providers so business spans and counters export to the collector.
- Override Aspire's default OTLP destination so telemetry flows to base14
Scout via a local OTel Collector instead of the Aspire dashboard's bundled
OTLP receiver.
- Run the same application code in two modes: Aspire AppHost for local
development, or a parallel Compose stack for CI and headless customer
environments.
- Avoid the common 13.x gotchas: `tcp://` URL serialization on `WithEndpoint`,
ephemeral project ports, missing custom-source registration, and dashboard
cert trust on macOS.
### Prerequisites
Before starting, ensure you have:
- **.NET 10.0 SDK** installed (Aspire 13 supports .NET 8+; 10 is recommended).
- Verify with `dotnet --version`.
- **Aspire CLI** installed (`dotnet tool install -g Aspire.Cli`).
- AppHost projects that set `AspireUseCliBundle=true` resolve the
orchestration and dashboard binaries from the CLI at build time.
- **Docker Desktop** with Apple Silicon native daemon and Rosetta enabled for
the `postgres:18.3` x86 image.
- **base14 Scout account** for OAuth2 export credentials.
- See [Scout setup guide](https://docs.base14.io/get-started/scout-setup).
- **NuGet** for package management.
- Basic familiarity with OpenTelemetry concepts (traces, spans, metrics,
resource attributes).
#### Compatibility Matrix
| Component | Minimum | Recommended |
| --- | --- | --- |
| .NET SDK | 8.0 | 10.0.400+ |
| ASP.NET Core | 8.0 | 10.0+ |
| .NET Aspire (AppHost + Hosting.*) | 9.5 | 13.5.2+ |
| OpenTelemetry .NET (core) | 1.10 | 1.18.0 |
| OpenTelemetry.Instrumentation.AspNetCore | 1.10 | 1.18.0 |
| OpenTelemetry.Instrumentation.Http | 1.10 | 1.18.0 |
| OpenTelemetry.Instrumentation.EntityFrameworkCore | 1.0-rc | 1.15.1-beta.1 (contrib beta) |
| OpenTelemetry.Instrumentation.Runtime | 1.10 | 1.18.0 |
| Entity Framework Core | 8.0 | 10.0.11 |
| Npgsql.EntityFrameworkCore.PostgreSQL | 8.0 | 10.0.3 |
| OTel Collector contrib | 0.140 | 0.151.0 |
> Aspire requires .NET 8 minimum. .NET Framework 4.8 is **not** supported.
> The OpenTelemetry instrumentation packages do not always release lockstep
> with the core SDK; pin per-package using the recommended versions above.
### Required Packages
#### AppHost project
```xml showLineNumbers title="AppHost/AppHost.csproj"
Exe
net10.0
true
true
```
The `Aspire.AppHost.Sdk` MSBuild SDK is referenced as a child `` element;
the project root SDK stays `Microsoft.NET.Sdk`. Setting
`true ` enables source generation of the
`Projects.*` typed resource names (`Projects.ArticlesApi`,
`Projects.NotifySvc`) used by `builder.AddProject("name")`.
#### ServiceDefaults project
```xml showLineNumbers title="ServiceDefaults/ServiceDefaults.csproj"
net10.0
true
```
`true ` and the
` ` together let
ServiceDefaults call `WebApplication`-typed extension methods.
### Quick Start
Clone the [`csharp/aspire-postgres`](https://github.com/base-14/examples/tree/main/csharp/aspire-postgres)
example and run it in Aspire mode:
```bash
git clone https://github.com/base-14/examples.git
cd examples/csharp/aspire-postgres
cp .env.example .env
# Edit .env with your Scout credentials.
make up # dotnet run --project AppHost/AppHost.csproj
```
The AppHost prints two log lines on startup:
```text
info: Aspire.Hosting.DistributedApplication[0]
Now listening on: http://localhost:15888
info: Aspire.Hosting.DistributedApplication[0]
Login to the dashboard at http://localhost:15888/login?t=<32-char-token>
```
Use the second URL (the login token regenerates each run; Aspire 13 has
dashboard auth on by default). Then in another terminal:
```bash
make test-api # exercises all 6 endpoints + cross-service trace
make verify-scout # confirms exports landed in Scout (requires SCOUT_*)
```
### .NET Aspire OpenTelemetry Concepts
#### ServiceDefaults: the cross-cutting OTel registration
Aspire's idiomatic shape is to put OTel configuration in a shared
ServiceDefaults class library that every project references. Each project's
`Program.cs` calls `builder.AddServiceDefaults()` and `app.MapDefaultEndpoints()`
to opt into the shared OTel, resilience, and health-check setup.
```csharp showLineNumbers title="ServiceDefaults/Extensions.cs"
public static class ServiceDefaultsExtensions
{
public const string ArticlesActivitySourceName = "AspirePostgres.Articles";
public const string ArticlesMeterName = "AspirePostgres.Articles";
public static TBuilder AddServiceDefaults(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler();
http.AddServiceDiscovery();
});
return builder;
}
public static TBuilder ConfigureOpenTelemetry(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
logging.ParseStateValues = true;
});
// Stamps TraceId/SpanId onto every log record so logs correlate with traces.
builder.Logging.Configure(options =>
{
options.ActivityTrackingOptions =
ActivityTrackingOptions.TraceId
| ActivityTrackingOptions.SpanId
| ActivityTrackingOptions.ParentId;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter(ArticlesMeterName))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(o => o.RecordException = true)
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddSource(ArticlesActivitySourceName));
if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]))
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
return builder;
}
}
```
Three patterns are critical here:
1. **`.AddSource("AspirePostgres.Articles")`** registers the custom
`ActivitySource` on the tracer provider. Without this call, custom spans
are silently dropped by the SDK.
2. **`.AddMeter("AspirePostgres.Articles")`** registers the custom `Meter` on
the meter provider. Same rule: without it, your `articles.created` counter
never reaches the OTLP exporter.
3. **`ActivityTrackingOptions.TraceId | SpanId | ParentId`** on
`builder.Logging.Configure(...)` stamps every `ILogger` record with the
active span's trace context. Without it, exported logs reach the OTLP
destination but with empty `trace_id` / `span_id` fields, breaking
log-trace correlation in Scout.
#### AppHost orchestration
The AppHost's `Program.cs` declares resources and wires them together:
```csharp showLineNumbers title="AppHost/AppHost.cs"
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("pg").WithImageTag("18.3");
var articlesDb = postgres.AddDatabase("articles");
var collector = builder.AddContainer(
"otel-collector",
"otel/opentelemetry-collector-contrib",
"0.151.0")
.WithBindMount("../config/otel-collector.yaml", "/etc/otel-collector.yaml")
.WithArgs("--config=/etc/otel-collector.yaml")
.WithHttpEndpoint(port: 4317, targetPort: 4317, name: "grpc")
.WithHttpEndpoint(port: 4318, targetPort: 4318, name: "http");
var collectorGrpc = collector.GetEndpoint("grpc");
var notify = builder.AddProject("notify-svc")
.WithHttpEndpoint(port: 8081, env: "ASPNETCORE_HTTP_PORTS")
.WithEnvironment("OTEL_EXPORTER_OTLP_ENDPOINT", collectorGrpc)
.WithEnvironment("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc")
.WithEnvironment("OTEL_SERVICE_NAME", "notify-svc")
.WaitFor(collector);
builder.AddProject("articles-api")
.WithHttpEndpoint(port: 8080, env: "ASPNETCORE_HTTP_PORTS")
.WithReference(articlesDb)
.WithEnvironment("Notify__BaseUrl", notify.GetEndpoint("http"))
.WithEnvironment("OTEL_EXPORTER_OTLP_ENDPOINT", collectorGrpc)
.WithEnvironment("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc")
.WithEnvironment("OTEL_SERVICE_NAME", "articles-api")
.WaitFor(postgres)
.WaitFor(collector);
builder.Build().Run();
```
#### The OTLP override knob
Aspire defaults `OTEL_EXPORTER_OTLP_ENDPOINT` on each project resource to its
bundled dashboard OTLP receiver. The AppHost override above replaces that
default with the local OTel Collector container's gRPC endpoint, which then
forwards to base14 Scout via `otlphttp` with OAuth2 authentication. This is
the single config switch that takes Aspire from "telemetry to local dashboard
only" to "telemetry to a vendor-neutral backend." No SDK code changes; just
the env var override.
ServiceDefaults' `ConfigureOpenTelemetry` only attaches the OTLP exporter
when `OTEL_EXPORTER_OTLP_ENDPOINT` is set (see `Extensions.cs`). In Aspire
mode AppHost always sets it, in Compose mode `compose.yml` sets it. If you
unset the env var entirely (e.g., running a project standalone outside Aspire
and Compose), no OTLP exporter is registered and telemetry stays in-process.
That is intentional - it keeps the SDK from spamming connection-refused
warnings to a non-existent collector.
The Aspire dashboard's resource panel, console log panel, and lifecycle
events continue to work normally; only the dashboard's Traces and Metrics
tabs go empty in this configuration. To populate them as well, add a second
`AddOtlpExporter` call inside `ConfigureOpenTelemetry` that targets
`${DOTNET_DASHBOARD_OTLP_ENDPOINT_URL}` (Aspire injects this automatically).
The example keeps the simpler single-exporter shape so the data flow remains
unambiguous.
### Custom Instrumentation
For business-level spans and metrics that auto-instrumentation cannot produce,
add a custom `ActivitySource` and `Meter`:
```csharp showLineNumbers title="ArticlesApi/Telemetry/AppMetrics.cs"
using System.Diagnostics;
using System.Diagnostics.Metrics;
public static class AppMetrics
{
public const string MeterName = "AspirePostgres.Articles";
public const string ActivitySourceName = "AspirePostgres.Articles";
public static readonly Meter Meter = new(MeterName);
public static readonly ActivitySource ActivitySource = new(ActivitySourceName);
public static readonly Counter ArticlesCreated =
Meter.CreateCounter("articles.created", description: "Total articles created");
}
```
Use them inside your endpoint logic:
```csharp showLineNumbers
using var activity = AppMetrics.ActivitySource.StartActivity("article.create");
// ... save article to db ...
AppMetrics.ArticlesCreated.Add(1);
activity?.SetTag("article.id", article.Id);
```
The `using` declaration scopes the activity to the method; the SDK closes it
when the method returns. The `article.id` tag becomes a queryable attribute in
Scout.
For more depth on .NET custom instrumentation patterns (parent-child spans,
async propagation, error recording, baggage), see
[Custom .NET instrumentation](../custom-instrumentation/csharp.md).
### Migrating from Azure-Monitor-anchored Aspire apps
Existing Aspire applications instrumented with the Azure Monitor OpenTelemetry
distro (`Azure.Monitor.OpenTelemetry.AspNetCore`) emit OTel-shaped data
already; Microsoft itself has done the wire-format work. Migrating to
Scout is a configuration change in three steps:
1. **Remove the Azure Monitor distro package** from the project's `.csproj`:
```bash
dotnet remove package Azure.Monitor.OpenTelemetry.AspNetCore
```
2. **Add the vendor-neutral OTLP exporter packages** that this guide pins, and
change the `Program.cs` (or `ServiceDefaults`) registration from
`builder.Services.AddOpenTelemetry().UseAzureMonitor(...)` to
`builder.Services.AddOpenTelemetry().UseOtlpExporter()` plus the
instrumentation calls listed in the ServiceDefaults section above.
3. **Override `OTEL_EXPORTER_OTLP_ENDPOINT`** at the AppHost level to point at
a Scout-forwarding OTel Collector. No code changes in the projects.
For a dual-export period during validation, keep both exporters live; the
.NET OpenTelemetry SDK supports multiple exporters on the same TracerProvider.
Once Scout dashboards confirm parity with Application Insights, remove the
Azure Monitor package.
A complete step-by-step migration playbook with KQL-to-Scout dashboard query
mappings will land in a follow-up guide; this section is the high-level
shape.
### Production Configuration
#### Resource attributes
Add `service.namespace` and `deployment.environment` so Scout dashboards can
filter across environments and service families:
```csharp showLineNumbers title="ServiceDefaults/Extensions.cs"
var environment = builder.Configuration["SCOUT_ENVIRONMENT"]
?? builder.Environment.EnvironmentName.ToLowerInvariant();
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddAttributes(new[]
{
new KeyValuePair("deployment.environment", environment),
new KeyValuePair("environment", environment),
new KeyValuePair("service.namespace", "examples"),
}))
.WithMetrics(metrics => metrics
// ... instrumentation registration ...
)
.WithTracing(tracing => tracing
// ... instrumentation registration ...
);
```
`service.name` is set automatically from `OTEL_SERVICE_NAME` by the OpenTelemetry
SDK defaults, which Aspire injects per project resource.
#### Batch processor tuning
The OTLP exporter batches by default. For high-throughput services, set:
```bash
OTEL_BSP_MAX_QUEUE_SIZE=2048
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
OTEL_BSP_SCHEDULE_DELAY=5000
OTEL_BSP_EXPORT_TIMEOUT=30000
```
For metrics, raise the export interval if Scout's metric ingest rate-limits
your traffic:
```bash
OTEL_METRIC_EXPORT_INTERVAL=10000
```
#### Sampling
The defaults trace 100% of requests, which is appropriate for development.
Production deployments behind a load balancer should switch to a head-based
sampler:
```bash
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
```
That samples 10% of root traces; child spans inherit the parent decision via
W3C `traceparent`. For more nuanced sampling (per-route, per-error), implement
a custom `Sampler` in code.
### Security Considerations
- **Do not log secrets in spans.** EF Core's contrib instrumentation can
optionally include SQL parameter values; the default in 1.15.1-beta.1 is to
omit them. Confirm with `OpenTelemetry.Instrumentation.EntityFrameworkCore`
release notes if you change the default.
- **Redact PII before logging.** Application logs flow through the OTel
logging provider into the collector and onward to Scout. Apply redaction at
the application layer or at the collector via a `transform` processor.
- **Protect the OAuth2 client credentials** in the collector config. The
example reads them from `.env` via Aspire's `IConfiguration`; production
deployments should pull them from an environment-aware secret store
(Azure Key Vault, AWS Secrets Manager, doppler, etc.) and never commit them
to git.
- **Aspire dashboard auth is on by default** in 13.x. Do not disable it for
any deployment beyond local development.
### Performance Considerations
- **Aspire dashboard overhead.** The dashboard runs as a separate .NET
process and consumes ~50-100 MB of resident memory. It is appropriate for
local development; do not deploy to production. Use Compose mode or a
cloud-deployed shape (`aspire publish`, Aspire 9.5+) for production.
- **Sampling defaults.** With a 100% trace sampler, every HTTP request emits
spans. At low traffic this is fine; at high traffic the OTLP exporter and
collector become the bottleneck. Switch to head-based sampling at 1-10%
for production.
- **Async export.** The `BatchSpanProcessor` exports asynchronously, so
request latency is not blocked on the collector. With the default batch
size, end-to-end export delay is 5-10 seconds.
- **`postgres:18.3` first pull on Apple Silicon** is ~310 MB and runs under
Rosetta. Expect 60-120 seconds for the first cold start; subsequent runs
reuse the cached image.
### Troubleshooting
#### Spans and metrics never appear in Scout
The most common cause is that Aspire serialized the collector's gRPC endpoint
as `tcp://localhost:4317` instead of `http://localhost:4317`. Use
`WithHttpEndpoint(port: 4317, targetPort: 4317, name: "grpc")` (not
`WithEndpoint`) when declaring the OTel Collector container. Confirm the
injected env var with:
```bash
ps eww $(pgrep -f ArticlesApi) | tr ' ' '\n' | grep OTEL_EXPORTER_OTLP_ENDPOINT
```
The value should start with `http://`.
#### Custom spans and counters not exporting
You forgot `.AddSource("Your.Source.Name")` and `.AddMeter("Your.Meter.Name")`
in `ConfigureOpenTelemetry`. The SDK silently drops anything not registered
on the tracer or meter provider. Names must match exactly between the
registration and the `new ActivitySource(...)` / `new Meter(...)` call site.
#### Aspire chooses an ephemeral port for my project
Aspire assigns ephemeral host ports to project resources by default. Pin a
known port with:
```csharp
.WithHttpEndpoint(port: 8080, env: "ASPNETCORE_HTTP_PORTS")
```
The `port:` argument requests a fixed published port; the `env:` argument
tells Aspire to also inject `ASPNETCORE_HTTP_PORTS=8080` so Kestrel binds to
the same target port inside the .NET runtime. DCP proxies between them.
#### Dashboard URL prints but the page returns 401
You are missing the `?t=` part of the URL. Aspire 13.x has dashboard
auth on by default; the token is regenerated each run. Use the full URL from
the `Login to the dashboard at` log line, not just `http://localhost:15888/`.
#### Macros: `dotnet workload install aspire` fails or hangs
You do not need it. Aspire 9.5+ uses the package-only AppHost SDK
(` `). NuGet restores the SDK
on `dotnet restore`; no workload install required.
#### Compose-mode build fails with `useradd: exit code 9`
The .NET runtime image (`mcr.microsoft.com/dotnet/aspnet:10.0`) ships with a
non-root `app` user pre-created since the .NET 8 release. Drop the `groupadd`
/ `useradd` lines from your Dockerfile and use `USER app` directly.
#### Behind a corporate proxy
Set `HTTPS_PROXY` and `NO_PROXY` on the AppHost process. The proxy applies to
NuGet restores, Docker pulls (configured separately on Docker Desktop), and
the OTel Collector's outbound connection to Scout. NoProxy should include
`localhost,127.0.0.1` so dashboard access stays direct.
### FAQ
#### Why does .NET Aspire need a custom OTLP destination instead of the dashboard?
The Aspire dashboard ships with a built-in OTLP receiver intended for local
development. To export to base14 Scout, override
`OTEL_EXPORTER_OTLP_ENDPOINT` on each project resource to point at a local
OTel Collector container. The collector then forwards to Scout via `otlphttp`
with OAuth2 authentication.
#### Which .NET and Aspire versions are supported?
.NET 8.0 minimum, .NET 10.0 recommended. Aspire 9.5+ uses the package-only
AppHost SDK; 13.x is the current generation. .NET Framework 4.8 is not
supported.
#### Why does `WithEndpoint` produce a `tcp://` URL that breaks the .NET OTLP exporter?
Aspire serializes `EndpointReference` values according to the endpoint kind.
`WithEndpoint(...)` produces `tcp://` for raw TCP endpoints. The .NET OTLP
exporter requires `http://` or `https://`. Use `WithHttpEndpoint(...)` for
any endpoint a .NET project will reach via OTLP, including OTLP/gRPC, since
gRPC runs over HTTP/2.
#### How do I pin a stable port for an Aspire project resource?
Call `WithHttpEndpoint(port: 8080, env: "ASPNETCORE_HTTP_PORTS")` on the
project resource. The `port:` argument requests a fixed host port; the `env:`
argument tells Aspire to also inject `ASPNETCORE_HTTP_PORTS` so Kestrel binds
to the same port inside the .NET runtime.
#### Does .NET Aspire register custom `ActivitySource`s and `Meter`s automatically?
No. Custom `ActivitySource`s require `.AddSource(name)` on the
TracerProviderBuilder, and custom `Meter`s require `.AddMeter(name)` on the
MeterProviderBuilder. Without these calls, the SDK never wires the source to
the OTLP exporter.
#### How do I migrate from Application Insights to OpenTelemetry plus Scout?
Remove the `Azure.Monitor.OpenTelemetry.AspNetCore` distro package, add
`OpenTelemetry.Exporter.OpenTelemetryProtocol`, and override
`OTEL_EXPORTER_OTLP_ENDPOINT` at the AppHost level to point at a
Scout-forwarding collector. Keep both exporters live during a dual-export
validation period.
#### Can I run an Aspire example without Aspire AppHost?
Yes. The example ships a parallel `compose.yml` stack that runs the same .NET
projects as Docker containers without Aspire AppHost. Both modes use the
same application code; only env-var sources differ.
### What's Next
- Clone the
[`csharp/aspire-postgres`](https://github.com/base-14/examples/tree/main/csharp/aspire-postgres)
example and run it end-to-end.
- Review the
[Custom .NET instrumentation guide](../custom-instrumentation/csharp.md)
for parent-child spans, async propagation, and error recording patterns.
- Set up
[Scout dashboards for .NET services](https://docs.base14.io/dashboards/dotnet)
to visualize the metrics and traces this guide produces.
- Read the
[Application Insights migration playbook](https://docs.base14.io/migrate/application-insights)
(publishing soon) for KQL-to-Scout query mappings and a stepwise dual-export
cutover plan.
#### Related Guides
- [.NET Custom Instrumentation](../custom-instrumentation/csharp.md) - Manual
spans and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
### References
- [.NET Aspire documentation](https://learn.microsoft.com/dotnet/aspire/)
- [.NET Aspire OpenTelemetry overview][aspire-otel-docs]
- [OpenTelemetry .NET SDK](https://opentelemetry.io/docs/languages/net/)
- [OpenTelemetry .NET on GitHub][otel-dotnet-gh]
- [OpenTelemetry .NET Contrib on GitHub][otel-dotnet-contrib-gh]
- [Migrate Application Insights to Azure Monitor OpenTelemetry][appinsights-migration]
- [base14 Scout](https://base14.io)
[aspire-otel-docs]: https://learn.microsoft.com/dotnet/aspire/fundamentals/telemetry
[otel-dotnet-gh]: https://github.com/open-telemetry/opentelemetry-dotnet
[otel-dotnet-contrib-gh]: https://github.com/open-telemetry/opentelemetry-dotnet-contrib
[appinsights-migration]: https://learn.microsoft.com/azure/azure-monitor/app/migrate-to-opentelemetry
---
## ASP.NET Core OpenTelemetry Instrumentation - Complete APM Setup Guide
## ASP.NET Core
Implement OpenTelemetry instrumentation for .NET ASP.NET Core applications to
enable comprehensive application performance monitoring (APM), distributed
tracing, and observability. This guide shows you how to auto-instrument your
ASP.NET Core application to collect traces, metrics, and logs from HTTP
requests, database queries, background jobs, and custom business logic using the
OpenTelemetry .NET SDK.
This guide covers OpenTelemetry for .NET applications. For cloud-native apps
using .NET Aspire, see the [.NET Aspire guide](./dotnet-aspire.md).
.NET applications benefit from first-class OpenTelemetry support with automatic
instrumentation for ASP.NET Core, Entity Framework Core, HttpClient, SQL Server,
and dozens of commonly used libraries. With OpenTelemetry, you can monitor
production performance, debug slow requests, trace distributed transactions
across microservices, and identify database bottlenecks without significant code
changes.
Whether you're implementing observability for the first time, migrating from
commercial APM solutions like Application Insights, or troubleshooting
performance issues in production, this guide provides production-ready
configurations and best practices for .NET OpenTelemetry instrumentation.
:::tip TL;DR
Add the `OpenTelemetry.Extensions.Hosting` and
`OpenTelemetry.AutoInstrumentation` NuGet packages, then wire up
`builder.Services.AddOpenTelemetry()` in `Program.cs` to register ASP.NET
Core, Entity Framework Core, and HttpClient instrumentation in one call.
Configure the OTLP exporter with your Scout collector endpoint and set
`OTEL_SERVICE_NAME` - no additional code is needed for HTTP request traces,
database spans, or outbound call tracking.
:::
> **Note:** This guide provides a practical ASP.NET Core-focused overview based
> on the official OpenTelemetry documentation. For complete .NET language
> information, please consult the
> [official OpenTelemetry .NET documentation](https://opentelemetry.io/docs/languages/net/).
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Who This Guide Is For
This documentation is designed for:
- **.NET developers:** implementing observability and distributed tracing for
ASP.NET Core applications
- **DevOps engineers:** deploying .NET applications with production monitoring
requirements
- **Engineering teams:** migrating from Application Insights, DataDog, or other
commercial APM solutions
- **Developers:** debugging performance issues, slow database queries, or N+1
problems in .NET applications
- **Platform teams:** standardizing observability across multiple .NET services
### Overview
This comprehensive guide demonstrates how to:
- Install and configure OpenTelemetry SDK for ASP.NET Core applications
- Set up automatic instrumentation for HTTP requests, database queries, and
popular libraries
- Configure production-ready telemetry export to Scout Collector
- Implement custom instrumentation for business-critical operations
- Collect and analyze traces, metrics, and logs
- Deploy instrumented .NET applications to development, staging, and production
environments
- Troubleshoot common instrumentation issues and optimize performance
- Secure sensitive data in telemetry exports
### Prerequisites
Before starting, ensure you have:
- **.NET 8.0 or later** (LTS version recommended)
- .NET 10.0 (LTS) is recommended; .NET 9.0 (STS) is out of support
- .NET 6.0+ is supported but may require additional configuration
- **ASP.NET Core 8.0 or later** installed
- **NuGet** for package management
- **Scout Collector** configured and accessible
- See
[Docker Compose Setup](../../collector-setup/docker-compose-example.md) for
local development
- Production deployments should use a dedicated Scout Collector instance
- Basic understanding of OpenTelemetry concepts (traces, spans, attributes)
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version |
| ----------------- | --------------- | ------------------- |
| .NET SDK | 6.0.0 | 10.0+ |
| ASP.NET Core | 6.0.0 | 10.0+ |
| OpenTelemetry | 1.7.0 | 1.11+ |
| Entity Framework | 6.0.0 | 10.0+ |
### Required Packages
Install the following NuGet packages:
```bash showLineNumbers
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Instrumentation.SqlClient
dotnet add package OpenTelemetry.Instrumentation.Runtime
```
Or add them to your `.csproj` file:
```xml showLineNumbers title="Api.csproj"
```
### Configuration
OpenTelemetry .NET instrumentation can be configured using multiple approaches
depending on your deployment requirements and preferences. Choose the method
that best fits your application architecture.
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
The recommended approach is to create a dedicated telemetry extension method.
This provides the most flexibility and keeps configuration separate from your
application bootstrap.
```csharp showLineNumbers title="Telemetry/TelemetrySetup.cs"
using OpenTelemetry.Exporter;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Api.Telemetry;
public static class TelemetrySetup
{
public static WebApplicationBuilder AddTelemetry(this WebApplicationBuilder builder)
{
var serviceName = builder.Configuration["OTEL_SERVICE_NAME"] ?? "dotnet-app";
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(serviceName)
.AddAttributes([
new KeyValuePair("deployment.environment",
builder.Environment.EnvironmentName.ToLowerInvariant()),
new KeyValuePair("environment",
builder.Environment.EnvironmentName.ToLowerInvariant())
]))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(options =>
{
options.RecordException = true;
})
.AddHttpClientInstrumentation()
.AddSqlClientInstrumentation(options =>
{
options.SetDbStatementForText = true;
options.RecordException = true;
})
.AddSource("MyApp.Services")
.AddOtlpExporter())
.WithMetrics(metrics => metrics
.SetExemplarFilter(ExemplarFilterType.TraceBased)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter("MyApp.Metrics")
.AddOtlpExporter());
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
logging.AddOtlpExporter();
});
return builder;
}
}
```
Use it in your `Program.cs`:
```csharp showLineNumbers title="Program.cs"
using Api.Telemetry;
var builder = WebApplication.CreateBuilder(args);
builder.AddTelemetry();
// ... rest of configuration
```
This configuration automatically instruments:
- **ASP.NET Core**: HTTP requests, middleware, controllers, Minimal APIs
- **HttpClient**: Outbound HTTP requests
- **SQL Server**: Database queries via SqlClient
- **Runtime**: GC, thread pool, and process metrics
```mdx-code-block
```
For simpler applications, configure OpenTelemetry directly in `Program.cs`:
```csharp showLineNumbers title="Program.cs"
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using OpenTelemetry.Metrics;
using OpenTelemetry.Logs;
var builder = WebApplication.CreateBuilder(args);
var serviceName = builder.Configuration["OTEL_SERVICE_NAME"] ?? "dotnet-app";
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService(serviceName))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter())
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddOtlpExporter());
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.AddOtlpExporter();
});
var app = builder.Build();
app.Run();
```
```mdx-code-block
```
For containerized deployments, OpenTelemetry .NET respects standard environment
variables:
```csharp showLineNumbers title="Program.cs"
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME") ?? "dotnet-app"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter()) // Uses OTEL_EXPORTER_OTLP_ENDPOINT automatically
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddOtlpExporter());
```
Configure with environment variables:
```bash showLineNumbers
export OTEL_SERVICE_NAME=dotnet-app
export OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4317
export OTEL_TRACES_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
```
```mdx-code-block
```
If you want to enable only specific instrumentations:
```csharp showLineNumbers title="Program.cs"
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService("dotnet-app"))
.WithTracing(tracing => tracing
// Only enable specific instrumentations
.AddAspNetCoreInstrumentation(options =>
{
options.RecordException = true;
options.Filter = context =>
{
// Skip health check endpoints
return !context.Request.Path.StartsWithSegments("/health");
};
})
.AddHttpClientInstrumentation(options =>
{
options.RecordException = true;
})
// Add custom ActivitySources
.AddSource("MyApp.AuthService")
.AddSource("MyApp.ArticleService")
.AddOtlpExporter());
```
```mdx-code-block
```
#### Configuring Instrumentation Options
Fine-tune instrumentation for your needs:
```csharp showLineNumbers title="Telemetry/TelemetrySetup.cs"
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(options =>
{
options.RecordException = true;
options.EnrichWithHttpRequest = (activity, request) =>
{
activity.SetTag("http.request_content_type",
request.ContentType);
};
options.EnrichWithHttpResponse = (activity, response) =>
{
activity.SetTag("http.response_content_length",
response.ContentLength);
};
options.Filter = context =>
{
// Skip static files and health checks
var path = context.Request.Path.Value ?? "";
return !path.StartsWith("/health") &&
!path.StartsWith("/favicon") &&
!path.StartsWith("/static");
};
})
.AddSqlClientInstrumentation(options =>
{
options.SetDbStatementForText = true;
options.SetDbStatementForStoredProcedure = true;
options.RecordException = true;
options.EnableConnectionLevelAttributes = true;
}));
```
#### Scout Collector Integration
When using Scout Collector, configure your .NET application to send telemetry
data to the Scout Collector endpoint:
```csharp showLineNumbers title="Telemetry/TelemetrySetup.cs"
public static WebApplicationBuilder AddTelemetry(this WebApplicationBuilder builder)
{
var serviceName = builder.Configuration["OTEL_SERVICE_NAME"] ?? "dotnet-app";
var scoutEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]
?? "http://localhost:4317";
var scoutApiKey = builder.Configuration["SCOUT_API_KEY"];
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(serviceName)
.AddAttributes([
new KeyValuePair("deployment.environment",
builder.Environment.EnvironmentName.ToLowerInvariant()),
new KeyValuePair("environment",
builder.Environment.EnvironmentName.ToLowerInvariant())
]))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(o => o.RecordException = true)
.AddHttpClientInstrumentation()
.AddSqlClientInstrumentation(o => o.SetDbStatementForText = true)
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(scoutEndpoint);
if (!string.IsNullOrEmpty(scoutApiKey))
{
options.Headers = $"x-scout-api-key={scoutApiKey}";
}
}));
return builder;
}
```
> **Scout Dashboard Integration**: After configuration, your traces will appear
> in the Scout Dashboard. Navigate to the Traces section to view request flows,
> identify performance bottlenecks, and analyze distributed transactions across
> your .NET services.
### Production Configuration
Production deployments require additional configuration for optimal performance,
reliability, and resource utilization. This section covers production-specific
settings and best practices.
#### Batch Span Processor (Default)
The OTLP exporter uses batch processing by default. Configure batch settings
for production:
```csharp showLineNumbers title="Telemetry/TelemetrySetup.cs"
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(scoutEndpoint);
options.ExportProcessorType = ExportProcessorType.Batch;
options.BatchExportProcessorOptions = new BatchExportProcessorOptions
{
MaxQueueSize = 2048,
ScheduledDelayMilliseconds = 5000,
ExporterTimeoutMilliseconds = 30000,
MaxExportBatchSize = 512
};
}));
```
**Benefits of Batch Processing:**
- Reduces network requests by up to 95%
- Lower CPU overhead compared to immediate export
- Prevents network saturation during traffic spikes
- Configurable batching for optimal throughput
#### Resource Attributes
Add rich context to all telemetry data with resource attributes:
```csharp showLineNumbers title="Telemetry/TelemetrySetup.cs"
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(
serviceName: serviceName,
serviceVersion: Assembly.GetExecutingAssembly()
.GetName().Version?.ToString() ?? "1.0.0",
serviceInstanceId: Environment.MachineName)
.AddAttributes([
new KeyValuePair("deployment.environment",
builder.Environment.EnvironmentName.ToLowerInvariant()),
new KeyValuePair("environment",
builder.Environment.EnvironmentName.ToLowerInvariant()),
new KeyValuePair("service.namespace", "production"),
new KeyValuePair("host.name", Environment.MachineName),
new KeyValuePair("process.runtime.name", ".NET"),
new KeyValuePair("process.runtime.version",
Environment.Version.ToString()),
]));
```
These attributes help you:
- Filter traces by environment, region, or instance
- Correlate issues with specific deployments
- Analyze performance across different infrastructure
- Debug production incidents faster
#### Environment-Based Configuration
Use `appsettings.json` for environment-specific configuration:
```json showLineNumbers title="appsettings.json"
{
"OTEL_SERVICE_NAME": "dotnet-app",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
```
```json showLineNumbers title="appsettings.Production.json"
{
"OTEL_SERVICE_NAME": "dotnet-app-production",
"OTEL_EXPORTER_OTLP_ENDPOINT": "https://scout-collector.example.com:4317",
"SCOUT_API_KEY": "",
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning"
}
}
}
```
#### Production Environment Variables
Configure production settings via environment variables:
```bash showLineNumbers title=".env.production"
# Service Configuration
OTEL_SERVICE_NAME=dotnet-app-production
ASPNETCORE_ENVIRONMENT=Production
# Scout Collector Endpoint
OTEL_EXPORTER_OTLP_ENDPOINT=https://scout-collector.example.com:4317
SCOUT_API_KEY=your-scout-api-key
# Database
ConnectionStrings__DefaultConnection=Server=db;Database=Production;User Id=app;Password=secret;
# JWT
Jwt__Secret=your-production-jwt-secret-minimum-32-characters
```
#### Docker Production Configuration
For containerized .NET applications, configure OpenTelemetry in your Docker
setup:
```dockerfile showLineNumbers title="Dockerfile"
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy project files
COPY *.csproj ./
RUN dotnet restore
# Copy source and build
COPY . .
RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# Install curl for healthcheck
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish .
ENV ASPNETCORE_ENVIRONMENT=Production
ENV OTEL_SERVICE_NAME=dotnet-app
ENV OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4317
EXPOSE 8080
ENTRYPOINT ["dotnet", "Api.dll"]
```
```yaml showLineNumbers title="docker-compose.yml"
services:
api:
build: .
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_HTTP_PORTS: "8080"
ConnectionStrings__DefaultConnection: "Server=sqlserver;Database=App;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=true"
Jwt__Secret: "your-super-secret-jwt-key-minimum-32-characters-long"
OTEL_SERVICE_NAME: dotnet-app
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
depends_on:
- sqlserver
- otel-collector
ports:
- "8080:8080"
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
ports:
- "4317:4317"
- "4318:4318"
sqlserver:
image: mcr.microsoft.com/azure-sql-edge:latest
environment:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: "YourStrong@Passw0rd"
```
### Metrics
In addition to traces, OpenTelemetry can collect metrics from your .NET
application to monitor resource utilization, request rates, error counts, and
custom business metrics.
#### Automatic HTTP Metrics
ASP.NET Core instrumentation automatically collects HTTP-related metrics:
```csharp showLineNumbers title="Telemetry/TelemetrySetup.cs"
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.SetExemplarFilter(ExemplarFilterType.TraceBased)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddOtlpExporter());
```
**Automatic metrics include:**
- `http.server.request.duration` - HTTP request duration histogram
- `http.server.active_requests` - Currently active requests
- `http.client.request.duration` - Outbound HTTP request duration
- `process.runtime.dotnet.gc.collections.count` - GC collections
- `process.runtime.dotnet.threadpool.threads.count` - Thread pool size
#### Custom Business Metrics
Create custom metrics to track business-specific events and KPIs:
```csharp showLineNumbers title="Telemetry/Metrics.cs"
using System.Diagnostics.Metrics;
namespace Api.Telemetry;
public static class AppMetrics
{
private static readonly Meter Meter = new("MyApp.Metrics");
public static readonly Counter UsersRegistered =
Meter.CreateCounter("users.registered",
description: "Total users registered");
public static readonly Counter LoginAttempts =
Meter.CreateCounter("auth.login.attempts",
description: "Total login attempts");
public static readonly Counter LoginFailures =
Meter.CreateCounter("auth.login.failures",
description: "Total failed login attempts");
public static readonly Counter ArticlesCreated =
Meter.CreateCounter("articles.created",
description: "Total articles created");
public static readonly Counter ArticlesUpdated =
Meter.CreateCounter("articles.updated",
description: "Total articles updated");
public static readonly Counter ArticlesDeleted =
Meter.CreateCounter("articles.deleted",
description: "Total articles deleted");
public static readonly Histogram OrderValue =
Meter.CreateHistogram("orders.value",
unit: "USD",
description: "Distribution of order values");
}
```
Use metrics in your services:
```csharp showLineNumbers title="Services/ArticleService.cs"
public async Task CreateAsync(int userId, CreateArticleRequest request)
{
// ... create article logic
// Record business metric
AppMetrics.ArticlesCreated.Add(1);
logger.LogInformation("Article created: {ArticleId} by user {UserId}",
article.Id, userId);
return response;
}
```
#### Viewing Metrics in Scout Dashboard
After configuring metrics export, navigate to the Scout Dashboard to:
- View HTTP request rate and latency percentiles (p50, p95, p99)
- Monitor error rates and status code distributions
- Track custom business metrics in real-time
- Create alerts based on metric thresholds
- Build custom dashboards combining metrics and traces
### Entity Framework Core Database Monitoring
OpenTelemetry automatically instruments Entity Framework Core to provide
comprehensive database query monitoring.
#### Automatic Query Tracing
EF Core emits diagnostic events that are captured by OpenTelemetry:
```csharp showLineNumbers title="Program.cs"
builder.Services.AddDbContext(options =>
options.UseSqlServer(connectionString)
.EnableSensitiveDataLogging(builder.Environment.IsDevelopment())
.EnableDetailedErrors(builder.Environment.IsDevelopment()));
```
#### Configuring SQL Client Instrumentation
Fine-tune SQL Client instrumentation for security and performance:
```csharp showLineNumbers title="Telemetry/TelemetrySetup.cs"
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddSqlClientInstrumentation(options =>
{
// Capture SQL statements (disable in production for sensitive data)
options.SetDbStatementForText = true;
options.SetDbStatementForStoredProcedure = true;
// Record exceptions as span events
options.RecordException = true;
// Add connection-level attributes
options.EnableConnectionLevelAttributes = true;
// Custom enrichment
options.Enrich = (activity, eventName, rawObject) =>
{
if (rawObject is SqlCommand command)
{
activity.SetTag("db.command_type", command.CommandType.ToString());
}
};
}));
```
**SQL span attributes include:**
- `db.system` - Database type (mssql)
- `db.name` - Database name
- `db.statement` - SQL query
- `db.operation.name` - Operation type
- `server.address` - Database server
#### Detecting N+1 Queries
Use OpenTelemetry traces to identify N+1 query problems:
```csharp showLineNumbers
// Bad: N+1 query pattern (visible in traces as multiple DB spans)
var articles = await context.Articles.ToListAsync();
foreach (var article in articles)
{
var author = await context.Users.FindAsync(article.AuthorId); // N+1!
}
// Good: Optimized with eager loading (single query in trace)
var articles = await context.Articles
.Include(a => a.Author)
.ToListAsync();
```
In Scout Dashboard, N+1 queries appear as:
- Multiple identical database spans within a single request trace
- High span count for simple operations
- Repeated query patterns with different parameters
### Custom Manual Instrumentation
While automatic instrumentation covers most ASP.NET Core components, you can add
custom instrumentation for business logic, external API calls, or
performance-critical code paths.
#### Creating Custom Spans with ActivitySource
Use `ActivitySource` for custom span creation:
```csharp showLineNumbers title="Services/ArticleService.cs"
using System.Diagnostics;
using Api.Telemetry;
using OpenTelemetry.Trace;
namespace Api.Services;
public class ArticleService
{
private static readonly ActivitySource ActivitySource =
new("MyApp.ArticleService");
private readonly AppDbContext _context;
private readonly ILogger _logger;
public ArticleService(AppDbContext context, ILogger logger)
{
_context = context;
_logger = logger;
}
public async Task CreateAsync(int userId, CreateArticleRequest request)
{
using var activity = ActivitySource.StartActivity("article.create");
activity?.SetTag("user.id", userId);
var slug = GenerateSlug(request.Title);
activity?.SetTag("article.slug", slug);
var article = new Article
{
Slug = slug,
Title = request.Title,
Description = request.Description,
Body = request.Body,
AuthorId = userId
};
_context.Articles.Add(article);
await _context.SaveChangesAsync();
activity?.SetTag("article.id", article.Id);
AppMetrics.ArticlesCreated.Add(1);
_logger.LogInformation("Article created: {ArticleId} by user {UserId}",
article.Id, userId);
return ToArticleResponse(article);
}
}
```
Don't forget to register your ActivitySource:
```csharp showLineNumbers title="Telemetry/TelemetrySetup.cs"
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddSource("MyApp.ArticleService")
.AddSource("MyApp.AuthService")
.AddOtlpExporter());
```
#### Adding Attributes to Current Spans
Enrich existing spans with additional context:
```csharp showLineNumbers title="Middleware/UserContextMiddleware.cs"
using System.Diagnostics;
public class UserContextMiddleware
{
private readonly RequestDelegate _next;
public UserContextMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var activity = Activity.Current;
if (context.User.Identity?.IsAuthenticated == true)
{
var userId = context.User.FindFirst("sub")?.Value;
var userRole = context.User.FindFirst("role")?.Value;
activity?.SetTag("user.id", userId);
activity?.SetTag("user.role", userRole);
activity?.SetTag("user.authenticated", true);
}
await _next(context);
}
}
```
#### Exception Handling and Error Tracking
Capture exceptions in custom spans:
```csharp showLineNumbers title="Services/ArticleService.cs"
public async Task UpdateAsync(string slug, int userId, UpdateArticleRequest request)
{
using var activity = ActivitySource.StartActivity("article.update");
activity?.SetTag("article.slug", slug);
activity?.SetTag("user.id", userId);
try
{
var article = await _context.Articles
.FirstOrDefaultAsync(a => a.Slug == slug);
if (article is null) return null;
if (article.AuthorId != userId)
{
var ex = new UnauthorizedAccessException("Not authorized to update this article");
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.AddException(ex);
throw ex;
}
// ... update logic
activity?.SetTag("article.id", article.Id);
AppMetrics.ArticlesUpdated.Add(1);
return ToArticleResponse(article);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update article {Slug}", slug);
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.AddException(ex);
throw;
}
}
```
#### Error Handling with Trace ID
Include trace IDs in error responses for easier debugging:
```csharp showLineNumbers title="Middleware/ExceptionMiddleware.cs"
using System.Diagnostics;
public static class ExceptionMiddlewareExtensions
{
public static IApplicationBuilder UseExceptionHandling(this IApplicationBuilder app)
{
return app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var traceId = Activity.Current?.TraceId.ToString();
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new
{
error = "Internal server error",
trace_id = traceId
});
});
});
}
}
```
#### Using Semantic Conventions
Follow OpenTelemetry semantic conventions for consistent attribute naming:
```csharp showLineNumbers
// HTTP semantic conventions
activity?.SetTag("http.method", "POST");
activity?.SetTag("http.url", "https://api.example.com/users");
activity?.SetTag("http.status_code", 201);
activity?.SetTag("http.request.header.content_type", "application/json");
// Database semantic conventions
activity?.SetTag("db.system", "mssql");
activity?.SetTag("db.name", "production");
activity?.SetTag("db.statement", "SELECT * FROM Users WHERE Id = @Id");
activity?.SetTag("db.operation", "SELECT");
// Messaging semantic conventions
activity?.SetTag("messaging.system", "rabbitmq");
activity?.SetTag("messaging.destination", "orders_queue");
activity?.SetTag("messaging.operation", "process");
```
### Running Your Instrumented Application
```mdx-code-block
```
For local development, console output is enabled by default:
```csharp showLineNumbers title="Program.cs"
var builder = WebApplication.CreateBuilder(args);
builder.AddTelemetry();
if (builder.Environment.IsDevelopment())
{
builder.Logging.AddConsole();
}
var app = builder.Build();
app.Run();
```
Start your application:
```bash
dotnet run
```
You'll see trace output in the console for each request.
```mdx-code-block
```
For production deployments, ensure the Scout Collector endpoint is properly
configured:
```bash showLineNumbers
# Set environment variables
export OTEL_SERVICE_NAME=dotnet-app-production
export OTEL_EXPORTER_OTLP_ENDPOINT=https://scout-collector.example.com:4317
export ASPNETCORE_ENVIRONMENT=Production
# Run the application
dotnet run --configuration Release
```
```mdx-code-block
```
Run your instrumented .NET application in Docker:
```bash showLineNumbers
# Build the image
docker build -t dotnet-app:latest .
# Run with Scout Collector
docker run -d \
--name dotnet-app \
-e OTEL_SERVICE_NAME=dotnet-app \
-e OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4317 \
-e ConnectionStrings__DefaultConnection="Server=db;Database=App;..." \
-p 8080:8080 \
dotnet-app:latest
```
Or use Docker Compose (see [Production Configuration](#production-configuration)
section for complete example).
```mdx-code-block
```
### Troubleshooting
#### Verifying OpenTelemetry Installation
Create a health check endpoint to verify telemetry:
```csharp showLineNumbers title="Endpoints/HealthEndpoints.cs"
using System.Diagnostics;
using System.Reflection;
public static class HealthEndpoints
{
private static readonly ActivitySource ActivitySource =
new("MyApp.Health");
public static void MapHealthEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/health", () =>
{
using var activity = ActivitySource.StartActivity("health.check");
activity?.SetTag("service.name",
Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME"));
activity?.SetTag("runtime.version",
Environment.Version.ToString());
return Results.Ok(new
{
status = "ok",
timestamp = DateTime.UtcNow,
version = Assembly.GetExecutingAssembly()
.GetName().Version?.ToString()
});
});
app.MapGet("/health/telemetry", () =>
{
using var activity = ActivitySource.StartActivity("telemetry.check");
return Results.Ok(new
{
status = "ok",
telemetry = new
{
trace_id = Activity.Current?.TraceId.ToString(),
span_id = Activity.Current?.SpanId.ToString(),
service_name = Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME"),
endpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT")
}
});
});
}
}
```
Test the endpoints:
```bash
curl http://localhost:8080/health/telemetry
```
#### Debug Mode
Enable debug logging to troubleshoot instrumentation issues:
```json showLineNumbers title="appsettings.Development.json"
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"OpenTelemetry": "Debug",
"Microsoft.AspNetCore": "Information"
}
}
}
```
#### Common Issues
##### Issue: No traces appearing in Scout Dashboard
**Solutions:**
1. Verify Scout Collector endpoint is reachable:
```bash
curl -v http://scout-collector:4317
```
2. Check environment variables:
```bash
echo $OTEL_EXPORTER_OTLP_ENDPOINT
echo $OTEL_SERVICE_NAME
```
3. Enable debug logging and check for export errors
4. Verify network connectivity between your app and Scout Collector
##### Issue: Missing database query spans
**Solutions:**
1. Ensure `OpenTelemetry.Instrumentation.SqlClient` package is installed
2. Verify SQL Client instrumentation is configured:
```csharp
.AddSqlClientInstrumentation(options =>
{
options.SetDbStatementForText = true;
})
```
3. Check that you're using `SqlClient` (not third-party providers)
##### Issue: High memory usage
**Solutions:**
1. Configure batch processor settings to reduce queue size
2. Ensure spans are being exported successfully
3. Limit span attribute sizes
##### Issue: Performance degradation
**Solutions:**
1. Use batch processor (default) instead of simple processor
2. Filter out high-frequency endpoints like health checks
3. Reduce logging verbosity in production
### Security Considerations
#### Protecting Sensitive Data
Avoid adding sensitive information to span attributes:
```csharp showLineNumbers
// Bad - exposes sensitive data
activity?.SetTag("user.password", password); // Never include passwords!
activity?.SetTag("credit_card.number", cardNumber); // Never include payment data!
activity?.SetTag("user.ssn", socialSecurity); // Never include PII!
// Good - uses safe identifiers
activity?.SetTag("user.id", userId);
activity?.SetTag("user.role", role);
activity?.SetTag("payment.provider", "stripe");
activity?.SetTag("payment.status", "completed");
```
#### Sanitizing SQL Statements
Disable SQL statement capture in production if queries contain sensitive data:
```csharp showLineNumbers title="Telemetry/TelemetrySetup.cs"
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddSqlClientInstrumentation(options =>
{
// Only enable in development
options.SetDbStatementForText = builder.Environment.IsDevelopment();
options.SetDbStatementForStoredProcedure = builder.Environment.IsDevelopment();
}));
```
#### Filtering Sensitive HTTP Headers
Skip sensitive headers in your tracing configuration:
```csharp showLineNumbers title="Telemetry/TelemetrySetup.cs"
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(options =>
{
options.EnrichWithHttpRequest = (activity, request) =>
{
// Only capture safe headers
activity.SetTag("http.request.header.content_type",
request.ContentType);
// Don't capture Authorization header
};
}));
```
#### Compliance Considerations
For applications handling regulated data (GDPR, HIPAA, PCI-DSS):
- Never include personally identifiable information (PII) in spans
- Use hashed or anonymized user identifiers
- Implement data retention policies in Scout Dashboard
- Disable SQL statement capture for sensitive queries
- Audit span attributes regularly for sensitive data leaks
### Performance Considerations
#### Expected Performance Impact
OpenTelemetry instrumentation adds minimal overhead to .NET applications:
- **Average latency increase**: 1-2ms per request
- **CPU overhead**: Less than 2% in production with batch processor
- **Memory overhead**: ~30-50MB depending on queue size and traffic
**Impact varies based on:**
- Number of enabled instrumentations
- Span processor type (Batch vs Simple)
- Application request volume
- Complexity of database queries
#### Optimization Best Practices
##### 1. Use Batch Processor (Default)
```csharp showLineNumbers
// Good - batches exports, low overhead (default)
.AddOtlpExporter(options =>
{
options.ExportProcessorType = ExportProcessorType.Batch;
})
// Avoid - exports every span immediately
.AddOtlpExporter(options =>
{
options.ExportProcessorType = ExportProcessorType.Simple;
})
```
##### 2. Filter Non-Critical Endpoints
```csharp showLineNumbers
.AddAspNetCoreInstrumentation(options =>
{
options.Filter = context =>
{
var path = context.Request.Path.Value ?? "";
return !path.StartsWith("/health") &&
!path.StartsWith("/metrics") &&
!path.StartsWith("/favicon");
};
})
```
##### 3. Conditional Span Recording
```csharp showLineNumbers
var activity = Activity.Current;
// Only add expensive attributes if activity is being recorded
if (activity?.IsAllDataRequested == true)
{
activity.SetTag("expensive.attribute", ComputeExpensiveValue());
}
```
##### 4. Limit Attribute Values
```csharp showLineNumbers
// Truncate long values
var truncatedBody = requestBody.Length > 1000
? requestBody[..1000] + "..."
: requestBody;
activity?.SetTag("http.request.body", truncatedBody);
```
### Frequently Asked Questions
#### Does OpenTelemetry impact .NET application performance?
OpenTelemetry adds approximately 1-2ms of latency per request in typical ASP.NET
Core applications. With proper configuration (batch processor), the performance
impact is minimal and acceptable for most production workloads.
#### Which .NET versions are supported?
OpenTelemetry supports .NET 6.0+ with full support. .NET 8.0+ is recommended for
optimal compatibility and performance. See the [Prerequisites](#prerequisites)
section for detailed version compatibility.
#### Can I use OpenTelemetry with background services?
Yes! You can instrument `IHostedService` and `BackgroundService` implementations
using `ActivitySource`:
```csharp
public class MyBackgroundService : BackgroundService
{
private static readonly ActivitySource ActivitySource =
new("MyApp.BackgroundService");
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var activity = ActivitySource.StartActivity("process_job");
// ... process job
}
}
}
```
#### Can I use OpenTelemetry alongside Application Insights?
Yes, you can run OpenTelemetry alongside Application Insights during migration
periods. However, running multiple telemetry systems simultaneously will
multiply the overhead, so plan your migration carefully. Consider using the
Azure Monitor OpenTelemetry exporter as a replacement.
#### How do I handle multi-tenant applications?
Add tenant context to spans using tags:
```csharp
Activity.Current?.SetTag("tenant.id", tenantId);
Activity.Current?.SetTag("tenant.name", tenantName);
```
Then filter traces by tenant in Scout Dashboard.
#### What's the difference between traces and metrics?
**Traces** show the complete request flow through your application with timing
details for each operation. Use traces to debug slow requests and understand
distributed transactions.
**Metrics** provide aggregated statistics over time (request rate, error rate,
latency percentiles). Use metrics for monitoring overall application health and
setting alerts.
#### How do I propagate trace context to message queues?
Inject the W3C `traceparent` header into the message when publishing and
extract it in the consumer, so the two sides join one trace:
```csharp
// When publishing
var activity = Activity.Current;
message.Headers["traceparent"] = activity?.Id;
// When consuming
var traceparent = message.Headers["traceparent"];
using var activity = ActivitySource.StartActivity(
"process_message",
ActivityKind.Consumer,
traceparent);
```
#### Can I customize which endpoints are instrumented?
Yes! Use the `Filter` option in ASP.NET Core instrumentation:
```csharp
options.Filter = context =>
{
return !context.Request.Path.StartsWithSegments("/internal");
};
```
### What's Next?
Now that your .NET application is instrumented with OpenTelemetry, explore these
resources to maximize your observability:
#### Advanced Topics
- **[PostgreSQL Monitoring Best Practices](../../component/postgres.md)** -
Optimize database observability with query performance analysis
#### Scout Platform Features
- **[Creating Alerts](../../../guides/creating-alerts-with-logx.md)** - Set up
intelligent alerts for error rates, latency thresholds, and custom metrics
- **[Dashboard Creation](../../../guides/create-your-first-dashboard.md)** -
Build custom dashboards combining traces, metrics, and business KPIs
#### Deployment and Operations
- **[Docker Compose Setup](../../collector-setup/docker-compose-example.md)** -
Set up Scout Collector for local development and testing
### Complete Example
Here's a complete working example of an ASP.NET Core application with
OpenTelemetry instrumentation:
#### Project File
```xml showLineNumbers title="Api.csproj"
net10.0
enable
enable
```
#### Telemetry Setup
```csharp showLineNumbers title="Telemetry/TelemetrySetup.cs"
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Api.Telemetry;
public static class TelemetrySetup
{
public static WebApplicationBuilder AddTelemetry(this WebApplicationBuilder builder)
{
var serviceName = builder.Configuration["OTEL_SERVICE_NAME"] ?? "dotnet-app";
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(serviceName)
.AddAttributes([
new KeyValuePair("deployment.environment",
builder.Environment.EnvironmentName.ToLowerInvariant()),
new KeyValuePair("environment",
builder.Environment.EnvironmentName.ToLowerInvariant())
]))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(o => o.RecordException = true)
.AddHttpClientInstrumentation()
.AddSqlClientInstrumentation(o => o.SetDbStatementForText = true)
.AddSource("MyApp.Services")
.AddOtlpExporter())
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter("MyApp.Metrics")
.AddOtlpExporter());
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.AddOtlpExporter();
});
return builder;
}
}
```
#### Metrics Definition
```csharp showLineNumbers title="Telemetry/Metrics.cs"
using System.Diagnostics.Metrics;
namespace Api.Telemetry;
public static class AppMetrics
{
private static readonly Meter Meter = new("MyApp.Metrics");
public static readonly Counter ArticlesCreated =
Meter.CreateCounter("articles.created",
description: "Total articles created");
}
```
#### Instrumented Service
```csharp showLineNumbers title="Services/ArticleService.cs"
using System.Diagnostics;
using Api.Telemetry;
namespace Api.Services;
public class ArticleService
{
private static readonly ActivitySource ActivitySource =
new("MyApp.Services");
public async Task CreateAsync(CreateArticleRequest request)
{
using var activity = ActivitySource.StartActivity("article.create");
var article = new Article
{
Title = request.Title,
Body = request.Body
};
// Save to database...
activity?.SetTag("article.id", article.Id);
AppMetrics.ArticlesCreated.Add(1);
return article;
}
}
```
#### Program Entry Point
```csharp showLineNumbers title="Program.cs"
using Api.Telemetry;
var builder = WebApplication.CreateBuilder(args);
builder.AddTelemetry();
var app = builder.Build();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.Run();
```
#### Environment Variables
```bash showLineNumbers title=".env"
OTEL_SERVICE_NAME=dotnet-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4317
ASPNETCORE_ENVIRONMENT=Development
```
This complete example is available in our
[GitHub examples repository](https://github.com/base-14/examples/tree/main/csharp/dotnet-sqlserver).
### References
- [Official OpenTelemetry .NET Documentation](https://opentelemetry.io/docs/languages/net/)
- [ASP.NET Core Documentation](https://learn.microsoft.com/aspnet/core)
- [OpenTelemetry .NET GitHub](https://github.com/open-telemetry/opentelemetry-dotnet)
### Related Guides
- [.NET Custom Instrumentation](../custom-instrumentation/csharp.md) - Manual
spans and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) - Set
up collector for local development
---
## Elixir Phoenix OpenTelemetry Instrumentation - Complete APM Setup Guide
## Elixir Phoenix
Implement OpenTelemetry instrumentation for `Phoenix` applications to collect
traces; monitor HTTP requests and database queries using the Elixir OTel SDK.
> **Note:** This guide provides a concise overview based on the official
> OpenTelemetry documentation. For complete information, please consult the
> [official OpenTelemetry documentation](https://opentelemetry.io/docs/languages/erlang/).
:::tip TL;DR
Add `opentelemetry_phoenix` and `opentelemetry_ecto` to your `mix.exs` deps,
then call `OpentelemetryPhoenix.setup()` and `OpentelemetryEcto.setup/1` in
your application's `start/2` function. HTTP requests and Ecto database queries
are traced automatically and exported to base14 Scout via OTLP.
:::
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Overview
This guide walks through setting up automatic OpenTelemetry instrumentation for
Phoenix applications, including HTTP request tracing, database query monitoring
with Ecto, and log correlation. The instrumentation automatically exports
telemetry data to Scout Collector for visualization.
### Prerequisites
Before starting, ensure you have:
- Elixir 1.13 or later installed
- Phoenix application set up
- Access to package installation (Mix)
- Scout collector endpoint
### Required Packages
Install the following necessary packages by adding them to `mix.exs` and
running `mix deps.get`.
```elixir title="mix.exs" showLineNumbers
defp deps do
[
# OpenTelemetry core packages
{:opentelemetry, "~> 1.3"},
{:opentelemetry_exporter, "~> 1.6"},
# Automatic instrumentation for Phoenix and Ecto
{:opentelemetry_phoenix, "~> 1.1"},
{:opentelemetry_ecto, "~> 1.1"}
]
end
```
After adding the dependencies, install them:
```bash
mix deps.get
```
### Traces
Traces give us the big picture of what happens when a request is made to an
application. Whether your application is a monolith with a single database or a
sophisticated mesh of services, traces are essential to understanding the full
"path" a request takes in your application.
#### Auto Instrumentation of Traces
##### Step 1: Initialize OpenTelemetry Instrumentation
Add the following setup calls in your application module's `start/2` function:
```elixir title="lib/phoenix_app/application.ex" showLineNumbers
def start(_type, _args) do
# Initialize Phoenix instrumentation
OpentelemetryPhoenix.setup()
# Initialize Ecto instrumentation
OpentelemetryEcto.setup([:phoenix_app, :repo])
# ... rest of your application setup
end
```
##### Step 2: Configure OpenTelemetry Exporter
Add OpenTelemetry configuration to your runtime configuration:
```elixir title="config/runtime.exs" showLineNumbers
import Config
# OpenTelemetry resource configuration
config :opentelemetry,
resource: [
service: [
name: "phoenix-app",
version: "1.0.0"
]
]
# OpenTelemetry exporter configuration
config :opentelemetry_exporter,
otlp_protocol: :http_protobuf,
otlp_endpoint: "http://localhost:4318"
```
> Replace `localhost:4318` with your Scout collector endpoint.
[Official Traces Documentation](https://opentelemetry.io/docs/concepts/signals/traces/)
##### Step 3: Configure Logger with Trace Context
Add trace context to your logs for correlation:
```elixir title="config/config.exs" showLineNumbers
config :logger, :default_formatter,
format: "$time [$level] $message trace_id=$otel_trace_id span_id=$otel_span_id\n",
metadata: [:otel_trace_id, :otel_span_id]
```
> View these traces in base14 Scout observability backend.
That's it! Head over to Scout to visualize the traces.
### References
- [OpenTelemetry Erlang Documentation](https://opentelemetry.io/docs/languages/erlang/)
- [Phoenix Framework Documentation](https://hexdocs.pm/phoenix/telemetry.html)
- [Sample Phoenix application with OpenTelemetry instrumentation](https://github.com/base-14/examples/tree/main/elixir/phoenix18-ecto3-postgres)
### Related Guides
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) - Set
up collector for local development
- [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md) -
Deploy collector on Kubernetes
---
## Elysia (Bun) OpenTelemetry Instrumentation - Complete APM Setup Guide
## Elysia (Bun)
Implement OpenTelemetry instrumentation for Elysia applications running on
the Bun runtime to enable distributed tracing, custom metrics, and structured
logging. This guide shows you how to use the OpenTelemetry Node.js SDK with
Bun's `--preload` flag, create manual spans for Elysia route handlers,
auto-instrument PostgreSQL queries through Drizzle ORM, and propagate trace
context across services -- all without relying on `getNodeAutoInstrumentations()`.
Elysia is a Bun-first framework that shares its lightweight, middleware-driven
design with [Hono](./hono.md).
Elysia on Bun requires a different instrumentation approach than traditional
Node.js frameworks. Because Bun does not use Node's `http` module internally,
HTTP auto-instrumentation cannot intercept Elysia requests. Instead, you create
targeted manual spans with a `traced()` wrapper function and rely on
`@opentelemetry/instrumentation-pg` for automatic database span generation.
The result is a lean, precise instrumentation setup with full control over span
names, attributes, and context propagation.
Whether you're building with Bun for its startup speed, migrating from
Node.js-based frameworks, or evaluating Elysia for a new microservice, this
guide provides production-ready configurations for OpenTelemetry on the Bun
runtime.
:::tip TL;DR
Create a `tracing.ts` file with `NodeSDK` + `PgInstrumentation` +
`LoggerProvider`, preload it with `bun run --preload ./src/tracing.ts`, and
wrap route handlers with a `traced()` helper that calls
`tracer.startActiveSpan()`. Database spans from Drizzle ORM are captured
automatically through the `pg` driver instrumentation.
:::
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Who This Guide Is For
This documentation is designed for:
- **Elysia developers**: adding observability to Bun-based APIs for the
first time
- **Bun adopters**: navigating the differences between Bun and Node.js
OpenTelemetry support
- **DevOps engineers**: deploying Elysia/Bun services with production
monitoring and container orchestration
- **Engineering teams**: migrating from DataDog, New Relic, or other
commercial APM solutions to open-source observability
- **Backend developers**: debugging performance issues or tracing requests
across multiple Bun-based microservices
### Overview
This guide demonstrates how to:
- Set up the OpenTelemetry Node.js SDK on the Bun runtime
- Preload instrumentation with `bun run --preload` for early initialization
- Create manual spans for Elysia route handlers using a `traced()` wrapper
- Auto-instrument PostgreSQL queries via `@opentelemetry/instrumentation-pg`
- Build a custom OTel logger with `@opentelemetry/api-logs` and stdout mirror
- Implement custom metrics with `articles.created` counters
- Propagate trace context across services with `propagation.inject/extract`
- Use Elysia's `t.Object()` validation with type-safe request bodies
- Export traces, metrics, and logs to base14 Scout via OTLP HTTP
- Deploy with Docker using `oven/bun:1.3-alpine` base images
#### Prerequisites
Before starting, ensure you have:
- **Bun 1.3 or later** installed
- **Elysia 1.4 or later** installed in your project
- **Scout Collector** configured and accessible from your application
- See [Docker Compose Setup](../../collector-setup/docker-compose-example.md)
for local development
- See [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md)
for production deployment
- **Basic understanding** of OpenTelemetry concepts (traces, spans, attributes)
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version | Notes |
| ---------------------------- | --------------- | ------------------- | ---------------------------------- |
| **Bun** | 1.1.0 | 1.3.x | Node.js compat layer required |
| **Elysia** | 1.0.0 | 1.4.x | Latest v1 with plugin system |
| **TypeScript** | 5.0.0 | 6.0.x | Bun includes TS transpiler |
| **OpenTelemetry SDK** | 0.200.0 | 0.214+ | Core SDK for traces/metrics |
| **@opentelemetry/api-logs** | 0.200.0 | 0.214+ | LogRecord API for structured logs |
| **instrumentation-pg** | 0.60.0 | 0.66.0+ | PostgreSQL auto-instrumentation |
| **PostgreSQL** | 15.0 | 18.x | For database instrumentation |
| **Drizzle ORM** | 0.40.0 | 0.45.x | Type-safe SQL via node-postgres |
#### Instrumented Components
| Component | Method | What You Get |
| ---------------- | ------------------- | --------------------------------------------- |
| HTTP routes | Manual spans | Route-level traces with status codes |
| PostgreSQL | Auto (pg driver) | Query spans with statement and duration |
| Drizzle ORM | Auto (via pg) | All Drizzle queries appear as database spans |
| Business metrics | Custom counter | `articles.created` count |
| Logging | OTel LoggerProvider | Structured logs with trace/span correlation |
| Cross-service | Manual propagation | Distributed traces across Bun services |
#### Example Application
The complete working example is available at
[elysia-postgres](https://github.com/base-14/examples/tree/main/bun/elysia-postgres).
It includes two Elysia services (app + notify), PostgreSQL with Drizzle ORM,
and a Scout Collector configuration.
### Installation
#### Core Packages
Install the required OpenTelemetry and application packages with Bun:
```bash showLineNumbers
bun add @opentelemetry/api
bun add @opentelemetry/sdk-node
bun add @opentelemetry/sdk-metrics
bun add @opentelemetry/exporter-trace-otlp-http
bun add @opentelemetry/exporter-metrics-otlp-http
bun add @opentelemetry/resources
bun add @opentelemetry/semantic-conventions
bun add @opentelemetry/instrumentation-pg
```
#### Logging Packages
```bash showLineNumbers
bun add @opentelemetry/api-logs
bun add @opentelemetry/sdk-logs
bun add @opentelemetry/exporter-logs-otlp-http
```
#### Application Packages
```bash showLineNumbers
bun add elysia
bun add drizzle-orm pg
bun add -d drizzle-kit @types/pg typescript
```
:::info Why not `getNodeAutoInstrumentations()`?
Bun's runtime does not use Node.js's internal `http` module for its HTTP
server. The `@opentelemetry/instrumentation-http` package -- which
`getNodeAutoInstrumentations()` relies on -- monkey-patches Node's `http`
module and has no effect on Elysia/Bun request handling. Instead, use targeted
instrumentations like `@opentelemetry/instrumentation-pg` for database spans
and create manual spans for HTTP route handlers.
:::
#### Tracing Setup
Create a `tracing.ts` file that initializes the OpenTelemetry SDK, metric
reader, and logger provider. This file runs before your application code
via Bun's `--preload` flag.
```typescript showLineNumbers title="src/tracing.ts"
import { logs } from "@opentelemetry/api-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { PgInstrumentation } from "@opentelemetry/instrumentation-pg";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { NodeSDK } from "@opentelemetry/sdk-node";
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
const endpoint =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://localhost:4318";
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? "elysia-articles",
[ATTR_SERVICE_VERSION]: process.env.OTEL_SERVICE_VERSION ?? "1.0.0",
});
const sdk = new NodeSDK({
resource,
traceExporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }),
exportIntervalMillis: parseInt(
process.env.OTEL_METRIC_EXPORT_INTERVAL || "10000"
),
}),
instrumentations: [new PgInstrumentation({ requireParentSpan: true })],
});
sdk.start();
const loggerProvider = new LoggerProvider({
processors: [
new BatchLogRecordProcessor(
new OTLPLogExporter({ url: `${endpoint}/v1/logs` })
),
],
});
logs.setGlobalLoggerProvider(loggerProvider);
process.on("SIGTERM", async () => {
await loggerProvider.shutdown();
await sdk.shutdown();
process.exit(0);
});
```
Key details in this setup:
- **OTLP HTTP exporters** with explicit `/v1/traces`, `/v1/metrics`, and
`/v1/logs` paths -- Bun's fetch-based HTTP client works reliably with HTTP
exporters (not gRPC)
- **`PgInstrumentation`** with `requireParentSpan: true` so database spans
only appear within the context of a request span, not from connection pool
health checks
- **Separate `LoggerProvider`** because the `NodeSDK` `logRecordProcessor`
option may not initialize correctly on all Bun versions -- setting the global
logger provider explicitly is more reliable
- **`SIGTERM` handler** for graceful shutdown in containerized deployments
### Configuration
#### Environment Variables
```bash showLineNumbers title=".env"
OTEL_SERVICE_NAME=elysia-articles
OTEL_SERVICE_VERSION=1.0.0
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_METRIC_EXPORT_INTERVAL=10000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/elysia_articles
NOTIFY_URL=http://localhost:8081
PORT=8080
```
#### Custom OTel Logger
Instead of using Pino or another logging library, this setup uses the
OpenTelemetry Logs API directly. Every log entry is emitted as an OTel
`LogRecord` with automatic trace correlation, plus a JSON line to stdout
for local debugging.
```typescript showLineNumbers title="src/logger.ts"
import { trace, context as otelContext } from "@opentelemetry/api";
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
const otelLogger = logs.getLogger("elysia-articles");
type LogAttrs = Record;
function emit(
severityNumber: SeverityNumber,
severityText: string,
message: string,
attrs?: LogAttrs
) {
const span = trace.getActiveSpan();
const ctx = span?.spanContext();
otelLogger.emit({
severityNumber,
severityText,
body: message,
context: otelContext.active(),
attributes: {
...attrs,
...(ctx ? { trace_id: ctx.traceId, span_id: ctx.spanId } : {}),
},
});
const record: Record = {
ts: new Date().toISOString(),
level: severityText,
msg: message,
...attrs,
...(ctx ? { trace_id: ctx.traceId, span_id: ctx.spanId } : {}),
};
const line = JSON.stringify(record);
if (severityNumber >= SeverityNumber.ERROR) {
process.stderr.write(`${line}\n`);
} else {
process.stdout.write(`${line}\n`);
}
}
export const logger = {
info: (msg: string, attrs?: LogAttrs) =>
emit(SeverityNumber.INFO, "INFO", msg, attrs),
warn: (msg: string, attrs?: LogAttrs) =>
emit(SeverityNumber.WARN, "WARN", msg, attrs),
error: (msg: string, attrs?: LogAttrs) =>
emit(SeverityNumber.ERROR, "ERROR", msg, attrs),
};
```
This approach has two advantages over Pino on Bun: it avoids the
`pino-opentelemetry-transport` worker thread (which has inconsistent behavior
on Bun), and it emits `LogRecord` objects with proper `context` for automatic
trace/span ID correlation in Scout.
#### Database with Drizzle ORM
Drizzle ORM uses the `node-postgres` (`pg`) adapter, which means every query
flows through a `pg.Pool` instance. The `PgInstrumentation` in `tracing.ts`
hooks into that pool to generate database spans automatically.
```typescript showLineNumbers title="src/schema.ts"
import { pgTable, serial, text, timestamp, varchar } from "drizzle-orm/pg-core";
export const articles = pgTable("articles", {
id: serial().primaryKey(),
title: varchar({ length: 255 }).notNull(),
body: text().notNull(),
createdAt: timestamp("created_at", { precision: 3 }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { precision: 3 }).notNull().defaultNow(),
});
```
```typescript showLineNumbers title="src/db.ts"
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });
```
Because `tracing.ts` is preloaded before `db.ts` is imported, the `pg` module
is already instrumented when the pool is created. Every Drizzle query --
`db.select()`, `db.insert()`, `db.update()`, `db.delete()` -- produces a
`pg.query` span with the SQL statement and execution time.
#### Docker Compose
The full development stack with both Elysia services, PostgreSQL, and the
Scout Collector:
```yaml showLineNumbers title="compose.yml"
services:
app:
build: ./app
ports:
- "8080:8080"
environment:
PORT: "8080"
DATABASE_URL: postgresql://postgres:postgres@db:5432/elysia_articles
NOTIFY_URL: http://notify:8081
OTEL_SERVICE_NAME: elysia-articles
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_METRIC_EXPORT_INTERVAL: "10000"
OTEL_RESOURCE_ATTRIBUTES: deployment.environment=${SCOUT_ENVIRONMENT:-development},environment=${SCOUT_ENVIRONMENT:-development},service.namespace=examples
depends_on:
db:
condition: service_healthy
otel-collector:
condition: service_started
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:8080/api/health",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
notify:
build: ./notify
ports:
- "8081:8081"
environment:
PORT: "8081"
OTEL_SERVICE_NAME: elysia-notify
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_METRIC_EXPORT_INTERVAL: "10000"
OTEL_RESOURCE_ATTRIBUTES: deployment.environment=${SCOUT_ENVIRONMENT:-development},environment=${SCOUT_ENVIRONMENT:-development},service.namespace=examples
depends_on:
otel-collector:
condition: service_started
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:8081/api/health",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
db:
image: postgres:18-alpine
environment:
POSTGRES_DB: elysia_articles
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
otel-collector:
image: otel/opentelemetry-collector-contrib:0.148.0
command: ["--config=/etc/otel/config.yaml"]
volumes:
- ./config/otel-config.yaml:/etc/otel/config.yaml:ro
ports:
- "4317:4317"
- "4318:4318"
- "13133:13133"
environment:
SCOUT_ENDPOINT: ${SCOUT_ENDPOINT:-http://localhost:4318}
SCOUT_CLIENT_ID: ${SCOUT_CLIENT_ID:-}
SCOUT_CLIENT_SECRET: ${SCOUT_CLIENT_SECRET:-}
SCOUT_TOKEN_URL: ${SCOUT_TOKEN_URL:-http://localhost/token}
SCOUT_ENVIRONMENT: ${SCOUT_ENVIRONMENT:-development}
healthcheck:
test: ["NONE"]
volumes:
pgdata:
```
#### Scout Collector Integration
The collector uses OAuth2 authentication to forward telemetry to Scout. Set
these environment variables before running `docker compose up`:
```bash showLineNumbers
export SCOUT_ENDPOINT=https://your-scout-endpoint.base14.io
export SCOUT_CLIENT_ID=your-client-id
export SCOUT_CLIENT_SECRET=your-client-secret
export SCOUT_TOKEN_URL=https://auth.base14.io/oauth/token
export SCOUT_ENVIRONMENT=development
```
The collector configuration uses the `oauth2client` extension for
authentication, `batch` processor for efficient export, and `memory_limiter`
for safety. Health check spans are filtered out via the `filter/noisy`
processor to reduce noise.
### Production Configuration
#### Production Environment Variables
```bash showLineNumbers title=".env.production"
OTEL_SERVICE_NAME=elysia-articles
OTEL_SERVICE_VERSION=1.2.0
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_METRIC_EXPORT_INTERVAL=60000
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=demo,environment=demo,service.namespace=articles
DATABASE_URL=postgresql://app_user:secure_password@db-primary:5432/articles_prod
NOTIFY_URL=http://notify:8081
PORT=8080
```
In production, increase `OTEL_METRIC_EXPORT_INTERVAL` to `60000` (60 seconds)
to reduce metric export frequency and collector load.
#### Dockerfile
Both services use a multi-stage build with `oven/bun:1.3-alpine` for minimal
image size. The `--preload` flag in the `CMD` ensures tracing initializes
before the application.
```dockerfile showLineNumbers title="app/Dockerfile"
FROM oven/bun:1.3-alpine AS deps
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
FROM oven/bun:1.3-alpine
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src/
RUN chown -R appuser:appgroup /app
USER appuser
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=5 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1
EXPOSE 8080
CMD ["bun", "run", "--preload", "./src/tracing.ts", "./src/index.ts"]
```
The `oven/bun:1.3-alpine` image is roughly 100MB -- significantly smaller
than most Node.js images. Bun's built-in TypeScript transpiler means no
separate build step is needed.
#### Multi-Service Tracing
The example application consists of two Bun services that communicate via
HTTP. Trace context flows from the app service to the notify service through
W3C `traceparent` headers, creating a single distributed trace across both
services.
**Outgoing side** -- inject trace context into fetch headers:
```typescript showLineNumbers title="src/notification.ts"
import { context, propagation } from "@opentelemetry/api";
import { logger } from "./logger";
const notifyUrl = process.env.NOTIFY_URL ?? "http://localhost:8081";
export async function notifyArticleCreated(articleId: number, title: string) {
const headers: Record = { "Content-Type": "application/json" };
propagation.inject(context.active(), headers);
try {
const res = await fetch(`${notifyUrl}/notify`, {
method: "POST",
headers,
body: JSON.stringify({ event: "article.created", article_id: articleId, title }),
});
if (!res.ok) {
logger.warn("Notify service returned non-OK", { status: res.status });
}
} catch (err) {
logger.warn("Notify service unreachable", { error: String(err) });
}
}
```
`propagation.inject()` writes the `traceparent` and `tracestate` headers into
the plain object. Bun's native `fetch` sends these headers to the downstream
service.
**Incoming side** -- extract trace context and create a child span:
```typescript showLineNumbers title="notify/src/index.ts"
import { Elysia, t } from "elysia";
import { trace, SpanKind, context, propagation } from "@opentelemetry/api";
import { logger } from "./logger";
const tracer = trace.getTracer("elysia-notify");
const PORT = parseInt(process.env.PORT || "8081");
const app = new Elysia()
.get("/api/health", () => ({ status: "healthy", service: "elysia-notify" }))
.post("/notify", async ({ body, request }) => {
const carrier: Record = {};
request.headers.forEach((value, key) => {
carrier[key] = value;
});
const parentCtx = propagation.extract(context.active(), carrier);
return trace.getTracer("elysia-notify").startActiveSpan(
"POST /notify",
{ kind: SpanKind.SERVER },
parentCtx,
async (span) => {
logger.info("Notification received", {
event: String(body.event),
article_id: Number(body.article_id),
});
span.setAttribute("notification.event", String(body.event));
span.setAttribute("notification.article_id", Number(body.article_id));
span.end();
return { status: "received" };
}
);
})
.listen(PORT);
logger.info("Notify service started", { port: PORT });
```
The notify service converts `request.headers` (a `Headers` object) into a
plain object so `propagation.extract()` can read the `traceparent` header.
The extracted context is passed as the third argument to `startActiveSpan`,
creating a child span that links back to the originating request in the app
service.
### Elysia-Specific Features
#### Plugin System
Elysia uses a plugin-based architecture where route groups are defined as
separate `Elysia` instances and composed with `.use()`:
```typescript showLineNumbers title="src/index.ts"
import { Elysia } from "elysia";
import { trace, SpanKind, SpanStatusCode } from "@opentelemetry/api";
import { logger } from "./logger";
import { healthRoutes } from "./routes/health";
import { articleRoutes } from "./routes/article";
const tracer = trace.getTracer("elysia-articles");
const PORT = parseInt(process.env.PORT || "8080");
const app = new Elysia()
.onError(({ code, error, set, request }) => {
const url = new URL(request.url);
return tracer.startActiveSpan(
`${request.method} ${url.pathname}`,
{ kind: SpanKind.SERVER },
(span) => {
if (code === "VALIDATION") {
logger.warn("Validation failed", { path: url.pathname });
span.setAttribute("http.response.status_code", 422);
span.setStatus({ code: SpanStatusCode.ERROR, message: "Validation failed" });
span.end();
set.status = 422;
return {
error: "Validation failed",
details: error.message,
meta: { trace_id: span.spanContext().traceId },
};
}
logger.error("Unhandled error", { error: String(error) });
span.setAttribute("http.response.status_code", 500);
span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
span.end();
set.status = 500;
return {
error: "Internal server error",
meta: { trace_id: span.spanContext().traceId },
};
}
);
})
.use(healthRoutes)
.use(articleRoutes)
.listen(PORT);
logger.info("Elysia articles server started", { port: PORT });
```
The `onError` hook creates a span for every unhandled error, capturing the
HTTP method, path, and status code. Validation errors from Elysia's built-in
`t.Object()` validators return 422 with a trace ID in the response body.
#### Type-Safe Routes with Validation
Elysia provides compile-time type inference from runtime validators. When you
define a body schema with `t.Object()`, both request validation and TypeScript
types are derived from a single source:
```typescript showLineNumbers
.post(
"/",
async ({ body, set }) =>
traced("POST /api/articles", set, async () => {
const [article] = await db
.insert(articles)
.values({ title: body.title, body: body.body })
.returning();
// body.title and body.body are type-checked at compile time
set.status = 201;
return { data: article, meta: { trace_id: getTraceId() } };
}),
{
body: t.Object({
title: t.String({ minLength: 1 }),
body: t.String({ minLength: 1 }),
}),
}
)
```
If validation fails, Elysia throws a `VALIDATION` error that the `onError`
hook captures and wraps in a span (see above).
#### The `traced()` Wrapper Pattern
Since Elysia on Bun cannot use HTTP auto-instrumentation, every route handler
is wrapped with a `traced()` function that manages span lifecycle:
```typescript showLineNumbers title="src/routes/article.ts"
const tracer = trace.getTracer("elysia-articles");
function traced(
name: string,
set: { status?: number | string },
fn: () => Promise
): Promise {
return tracer.startActiveSpan(name, { kind: SpanKind.SERVER }, async (span) => {
try {
const result = await fn();
const status = typeof set.status === "number" ? set.status : 200;
span.setAttribute("http.response.status_code", status);
if (status >= 400) span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
return result;
} catch (err) {
span.setAttribute("http.response.status_code", 500);
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
span.end();
throw err;
}
});
}
```
This wrapper:
- Creates a `SERVER` span with the route name (e.g., `GET /api/articles`)
- Reads the response status code from Elysia's `set` object after the
handler completes
- Marks spans as `ERROR` for 4xx and 5xx responses
- Ensures `span.end()` is called in both success and error paths
- Propagates the active context so that database queries within `fn()`
become child spans
#### Drizzle ORM Auto-Tracing via PgInstrumentation
Because Drizzle ORM uses the `pg` driver internally, all database operations
are automatically instrumented. A single route handler like this:
```typescript showLineNumbers
const [rows, [{ total }]] = await Promise.all([
db
.select()
.from(articles)
.orderBy(desc(articles.createdAt))
.limit(perPage)
.offset(offset),
db.select({ total: count() }).from(articles),
]);
```
Generates a span hierarchy like:
```text
GET /api/articles (SERVER)
├── pg.query:SELECT (CLIENT) — article rows
└── pg.query:SELECT (CLIENT) — count query
```
Each `pg.query` span includes the SQL statement (with parameter values
obfuscated by default), execution duration, database name, and host.
#### onError Hook
The global `onError` hook ensures that even failed requests produce spans
with meaningful error information:
```typescript showLineNumbers
.onError(({ code, error, set, request }) => {
const url = new URL(request.url);
return tracer.startActiveSpan(
`${request.method} ${url.pathname}`,
{ kind: SpanKind.SERVER },
(span) => {
if (code === "VALIDATION") {
span.setAttribute("http.response.status_code", 422);
span.setStatus({ code: SpanStatusCode.ERROR, message: "Validation failed" });
span.end();
set.status = 422;
return { error: "Validation failed", details: error.message };
}
// ... handle other errors
}
);
})
```
Elysia's error codes (`VALIDATION`, `NOT_FOUND`, `INTERNAL_SERVER_ERROR`,
`PARSE`) let you distinguish error types in span attributes for targeted
alerting.
### Custom Instrumentation
#### Business Metrics with Counters
Track application-level metrics alongside trace data. The `articles.created`
counter increments every time a new article is successfully inserted:
```typescript showLineNumbers title="src/routes/article.ts"
import { metrics } from "@opentelemetry/api";
const meter = metrics.getMeter("elysia-articles");
const articlesCreated = meter.createCounter("articles.created", {
description: "Number of articles created",
});
// Inside the POST handler:
const [article] = await db
.insert(articles)
.values({ title: body.title, body: body.body })
.returning();
articlesCreated.add(1);
```
This counter is exported via the `PeriodicExportingMetricReader` configured
in `tracing.ts` and appears in Scout as a time-series metric.
#### Trace ID in Responses
Every API response includes a `trace_id` field so callers can reference the
exact trace when reporting issues:
```typescript showLineNumbers
function getTraceId(): string {
return trace.getActiveSpan()?.spanContext().traceId ?? "";
}
// Used in responses:
return { data: article, meta: { trace_id: getTraceId() } };
```
This is especially useful during development and debugging -- the trace ID
links directly to the full distributed trace in Scout.
#### Manual Spans with startActiveSpan
For operations that need additional detail beyond what `traced()` provides,
create nested spans directly:
```typescript showLineNumbers
import { trace, SpanKind } from "@opentelemetry/api";
const tracer = trace.getTracer("elysia-articles");
async function enrichArticle(articleId: number) {
return tracer.startActiveSpan(
"enrichArticle",
{ kind: SpanKind.INTERNAL },
async (span) => {
span.setAttribute("article.id", articleId);
try {
const result = await performEnrichment(articleId);
span.end();
return result;
} catch (err) {
span.recordException(err as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: (err as Error).message,
});
span.end();
throw err;
}
}
);
}
```
When called inside a `traced()` handler, this span becomes a child of the
route span, creating a detailed breakdown of the request processing steps.
#### Complete Route Example
Here is the full article routes file with all instrumentation patterns
combined -- the `traced()` wrapper, business counter, trace ID in responses,
type-safe validation, and notification with context propagation:
```typescript showLineNumbers title="src/routes/article.ts"
import { Elysia, t } from "elysia";
import { eq, desc, count, sql } from "drizzle-orm";
import { trace, SpanKind, SpanStatusCode } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import { db } from "../db";
import { articles } from "../schema";
import { logger } from "../logger";
import { notifyArticleCreated } from "../notification";
const tracer = trace.getTracer("elysia-articles");
const meter = metrics.getMeter("elysia-articles");
const articlesCreated = meter.createCounter("articles.created", {
description: "Number of articles created",
});
function getTraceId(): string {
return trace.getActiveSpan()?.spanContext().traceId ?? "";
}
function traced(
name: string,
set: { status?: number | string },
fn: () => Promise
): Promise {
return tracer.startActiveSpan(name, { kind: SpanKind.SERVER }, async (span) => {
try {
const result = await fn();
const status = typeof set.status === "number" ? set.status : 200;
span.setAttribute("http.response.status_code", status);
if (status >= 400) span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
return result;
} catch (err) {
span.setAttribute("http.response.status_code", 500);
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
span.end();
throw err;
}
});
}
export const articleRoutes = new Elysia({ prefix: "/api/articles" })
.get("/", async ({ query, set }) =>
traced("GET /api/articles", set, async () => {
const page = Number(query.page) || 1;
const perPage = Number(query.per_page) || 20;
const offset = (page - 1) * perPage;
const [rows, [{ total }]] = await Promise.all([
db
.select()
.from(articles)
.orderBy(desc(articles.createdAt))
.limit(perPage)
.offset(offset),
db.select({ total: count() }).from(articles),
]);
logger.info("Listed articles", { page, per_page: perPage, total });
return {
data: rows,
meta: {
page,
per_page: perPage,
total,
trace_id: getTraceId(),
},
};
})
)
.post(
"/",
async ({ body, set }) =>
traced("POST /api/articles", set, async () => {
const [article] = await db
.insert(articles)
.values({ title: body.title, body: body.body })
.returning();
articlesCreated.add(1);
logger.info("Article created", { id: article.id, title: article.title });
await notifyArticleCreated(article.id, article.title);
set.status = 201;
return { data: article, meta: { trace_id: getTraceId() } };
}),
{
body: t.Object({
title: t.String({ minLength: 1 }),
body: t.String({ minLength: 1 }),
}),
}
)
.get("/:id", async ({ params, set }) =>
traced("GET /api/articles/:id", set, async () => {
const id = Number(params.id);
if (isNaN(id) || !Number.isInteger(id) || id < 1) {
logger.warn("Invalid article ID format", { raw_id: params.id });
set.status = 400;
return {
error: "Invalid ID format",
details: "ID must be a positive integer",
meta: { trace_id: getTraceId() },
};
}
const [article] = await db
.select()
.from(articles)
.where(eq(articles.id, id));
if (!article) {
logger.warn("Article not found", { id });
set.status = 404;
return { error: "Article not found", meta: { trace_id: getTraceId() } };
}
return { data: article, meta: { trace_id: getTraceId() } };
})
)
.put(
"/:id",
async ({ params, body, set }) =>
traced("PUT /api/articles/:id", set, async () => {
const id = Number(params.id);
if (isNaN(id) || !Number.isInteger(id) || id < 1) {
set.status = 400;
return {
error: "Invalid ID format",
meta: { trace_id: getTraceId() },
};
}
const updates: Record = {
updatedAt: new Date(),
};
if (body.title) updates.title = body.title;
if (body.body) updates.body = body.body;
const [article] = await db
.update(articles)
.set(updates)
.where(eq(articles.id, id))
.returning();
if (!article) {
logger.warn("Article not found for update", { id });
set.status = 404;
return { error: "Article not found", meta: { trace_id: getTraceId() } };
}
logger.info("Article updated", { id });
return { data: article, meta: { trace_id: getTraceId() } };
}),
{
body: t.Partial(
t.Object({
title: t.String({ minLength: 1 }),
body: t.String({ minLength: 1 }),
})
),
}
)
.delete("/:id", async ({ params, set }) =>
traced("DELETE /api/articles/:id", set, async () => {
const id = Number(params.id);
if (isNaN(id) || !Number.isInteger(id) || id < 1) {
set.status = 400;
return { error: "Invalid ID format", meta: { trace_id: getTraceId() } };
}
const [article] = await db
.delete(articles)
.where(eq(articles.id, id))
.returning();
if (!article) {
logger.warn("Article not found for delete", { id });
set.status = 404;
return { error: "Article not found", meta: { trace_id: getTraceId() } };
}
logger.info("Article deleted", { id });
set.status = 204;
})
);
```
### Running Your Application
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
Start the application in development mode with watch and preload:
```bash showLineNumbers
bun run --watch --preload ./src/tracing.ts ./src/index.ts
```
Or use the `package.json` scripts:
```bash showLineNumbers
bun run dev
```
```mdx-code-block
```
```bash showLineNumbers
docker compose up --build
docker compose logs -f app notify
docker compose down
```
```mdx-code-block
```
#### Verification
After starting the application, create an article and verify spans are
generated:
```bash showLineNumbers
curl -X POST http://localhost:8080/api/articles \
-H "Content-Type: application/json" \
-d '{"title": "Hello Elysia", "body": "First post with OpenTelemetry tracing"}'
```
Expected response:
```json
{
"data": {
"id": 1,
"title": "Hello Elysia",
"body": "First post with OpenTelemetry tracing",
"createdAt": "2026-03-31T10:00:00.000Z",
"updatedAt": "2026-03-31T10:00:00.000Z"
},
"meta": {
"trace_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
}
}
```
The `trace_id` in the response maps to this span hierarchy in Scout:
```text
elysia-articles: POST /api/articles (SERVER, 201)
├── pg.query:INSERT (CLIENT) — insert article
└── elysia-notify: POST /notify (SERVER)
└── [notification processing]
```
List articles to verify pagination and database spans:
```bash showLineNumbers
curl http://localhost:8080/api/articles?page=1&per_page=10
```
Check the health endpoint:
```bash showLineNumbers
curl http://localhost:8080/api/health
```
### Troubleshooting
#### Common Issues
##### Issue: Preload not loading tracing.ts
**Symptoms**: No spans or metrics appear. Application starts normally but
the collector receives no data.
**Solutions:**
1. Verify the `--preload` flag is before the entry point in the command:
```bash showLineNumbers
# Correct
bun run --preload ./src/tracing.ts ./src/index.ts
# Wrong — preload after entry point is ignored
bun run ./src/index.ts --preload ./src/tracing.ts
```
2. Check that `tracing.ts` does not import any application modules (it should
only import `@opentelemetry/*` packages)
3. Confirm the file path is relative to the working directory, not the `src`
folder
##### Issue: PgInstrumentation not capturing database spans
**Symptoms**: Route spans appear but no `pg.query` child spans.
**Solutions:**
1. Ensure `tracing.ts` is preloaded before `pg` is imported. The
instrumentation must patch `pg` before any `Pool` or `Client` is created
2. Verify you are using `pg` (node-postgres), not `postgres` (postgres.js) --
`PgInstrumentation` only supports the `pg` package
3. Check that `requireParentSpan: true` is not filtering out spans -- try
setting it to `false` temporarily to confirm spans appear
##### Issue: Manual context propagation not linking traces
**Symptoms**: The app service and notify service produce separate, unlinked
traces instead of a single distributed trace.
**Solutions:**
1. Verify `propagation.inject()` is called within an active span context.
If called outside a `traced()` wrapper, there is no active context to
propagate
2. On the receiving side, convert `request.headers` to a plain object before
calling `propagation.extract()` -- the W3C propagator expects a simple
key-value carrier, not a `Headers` instance
3. Pass the extracted context as the third argument to `startActiveSpan`:
```typescript showLineNumbers
const parentCtx = propagation.extract(context.active(), carrier);
tracer.startActiveSpan("span-name", { kind: SpanKind.SERVER }, parentCtx, (span) => {
// ...
});
```
##### Issue: OTLP export failures on Bun
**Symptoms**: Console shows connection errors or timeout warnings from
OTLP exporters.
**Solutions:**
1. Use HTTP exporters (port 4318), not gRPC (port 4317). Bun does not support
gRPC natively
2. Verify the endpoint URL includes the signal-specific path:
`http://collector:4318/v1/traces` (not just `http://collector:4318`)
3. Check that the collector is running and reachable from the Bun process.
In Docker Compose, use the service name as hostname
##### Issue: LoggerProvider not emitting logs
**Symptoms**: Trace and metric data appears in Scout but no log records.
**Solutions:**
1. Ensure `logs.setGlobalLoggerProvider(loggerProvider)` is called in
`tracing.ts` after creating the `LoggerProvider`
2. Verify the logger calls `otelLogger.emit()` with the `context` field set
to `otelContext.active()` for trace correlation
3. Check that the log exporter URL ends with `/v1/logs`
#### Debug Mode
Enable verbose SDK logging to diagnose initialization issues:
```bash showLineNumbers
OTEL_LOG_LEVEL=debug bun run --preload ./src/tracing.ts ./src/index.ts
```
### Security Considerations
#### SQL Query Obfuscation
`PgInstrumentation` obfuscates SQL parameter values by default. Query
statements appear in spans as:
```text
INSERT INTO "articles" ("title", "body") VALUES ($1, $2) RETURNING *
```
Parameter values (`$1`, `$2`) are never captured in span attributes. To
verify this behavior, avoid setting `enhancedDatabaseReporting: true` in
production:
```typescript showLineNumbers
// Safe default — parameter values are obfuscated
new PgInstrumentation({ requireParentSpan: true })
// AVOID in production — captures actual parameter values
new PgInstrumentation({ enhancedDatabaseReporting: true })
```
#### PII Protection
Prevent sensitive data from leaking into telemetry:
```typescript showLineNumbers
// BAD: Captures user email in span
span.setAttribute("user.email", email);
// GOOD: Only capture non-sensitive identifiers
span.setAttribute("user.id", userId);
span.setAttribute("user.email_domain", email.split("@")[1]);
```
For the custom OTel logger, be cautious with structured attributes:
```typescript showLineNumbers
// BAD: Logs request body that might contain passwords
logger.info("Request received", { body: JSON.stringify(req.body) });
// GOOD: Log only safe identifiers
logger.info("Article created", { id: article.id, title: article.title });
```
#### Compliance Considerations
For GDPR, HIPAA, or PCI-DSS compliance:
- Never log PII in span attributes or log record attributes
- Use pseudonymization for user identifiers when possible
- Configure data retention policies in your observability backend
- Implement attribute filtering at the collector level using the
`transform` processor
### Performance Considerations
#### Bun Runtime Advantages
Bun's startup time is typically 3-5x faster than Node.js, which means the
overhead of preloading `tracing.ts` is minimal -- usually under 100ms.
The OpenTelemetry SDK initialization adds roughly 50-80ms to cold start,
compared to 150-300ms on Node.js.
#### Expected Impact
| Metric | Typical Impact | High-Traffic Impact |
| ------------ | -------------- | ------------------- |
| Latency | +1-2ms | +2-4ms |
| CPU overhead | 2-4% | 4-8% |
| Memory | +30-60MB | +60-120MB |
Bun's lower baseline memory usage means the absolute overhead of
OpenTelemetry is smaller than on Node.js.
#### Batch Export Tuning
The `PeriodicExportingMetricReader` and `BatchLogRecordProcessor` buffer data
before export. Adjust these for your traffic patterns:
```typescript showLineNumbers
// Development: frequent exports for fast feedback
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }),
exportIntervalMillis: 10000, // every 10 seconds
}),
// Production: less frequent exports to reduce overhead
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }),
exportIntervalMillis: 60000, // every 60 seconds
}),
```
#### Skip Health Check Spans
Health check endpoints generate high-volume, low-value spans. The collector
configuration filters these out with the `filter/noisy` processor:
```yaml showLineNumbers
filter/noisy:
error_mode: ignore
traces:
span:
- 'IsMatch(name, ".*health.*")'
```
This keeps health checks functional for container orchestrators while
preventing span noise in Scout.
### FAQ
#### Does OpenTelemetry work with Bun?
Yes. Bun supports the OpenTelemetry Node.js SDK (`@opentelemetry/sdk-node`)
through its Node.js compatibility layer. The `NodeSDK` class, OTLP HTTP
exporters, and targeted instrumentations like `@opentelemetry/instrumentation-pg`
work correctly. However, `getNodeAutoInstrumentations()` does not fully work
because Bun does not use Node's internal `http`, `net`, `dns`, or `fs`
modules for its core operations.
#### Why do I need manual spans in Elysia instead of auto-instrumentation?
Bun's HTTP server does not go through Node.js's `http.createServer()`, so
`@opentelemetry/instrumentation-http` has nothing to patch. The `traced()`
wrapper pattern gives you explicit control over span names (e.g.,
`GET /api/articles/:id` instead of generic `HTTP GET`) and lets you
set response status codes from Elysia's `set` object.
#### How does Drizzle ORM get instrumented without explicit setup?
Drizzle ORM with the `drizzle-orm/node-postgres` adapter delegates all SQL
execution to a `pg.Pool` instance. The `PgInstrumentation` patches the `pg`
module at the driver level, so every query that flows through the pool --
whether from Drizzle's query builder, raw SQL, or transactions -- generates
a `pg.query` span automatically.
#### How do I propagate trace context between Bun services?
On the sending side, call `propagation.inject(context.active(), headers)`
to write `traceparent` and `tracestate` headers into a plain object, then
pass that object to `fetch`. On the receiving side, extract headers into a
plain object and call `propagation.extract(context.active(), carrier)` to
get the parent context. Pass this context to `startActiveSpan` as the third
argument.
#### What is the difference between OTLP HTTP and gRPC on Bun?
Use OTLP HTTP (port 4318). Bun does not have native gRPC support, and the
`@grpc/grpc-js` package has compatibility issues on Bun. HTTP exporters work
reliably with Bun's native `fetch` implementation and support HTTP proxies
and load balancers.
#### How do I use Drizzle ORM vs Prisma with OpenTelemetry on Bun?
Drizzle ORM with `node-postgres` works well because `PgInstrumentation`
patches the underlying `pg` driver. Prisma uses its own query engine binary,
which bypasses the `pg` driver entirely -- `PgInstrumentation` cannot capture
Prisma queries. If you use Prisma, you need Prisma's built-in tracing
integration (`previewFeatures = ["tracing"]`) or its OpenTelemetry extension.
#### Can I use Pino logging instead of the custom OTel logger?
You can, but the `pino-opentelemetry-transport` package uses Node.js worker
threads, which have inconsistent behavior on Bun. The custom OTel logger
approach in this guide uses `@opentelemetry/api-logs` directly, avoiding
worker threads entirely while providing the same trace correlation and
structured log export.
#### How do I add custom attributes to all spans?
Set `OTEL_RESOURCE_ATTRIBUTES` as an environment variable:
```bash showLineNumbers
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development,environment=development,service.namespace=articles
```
These attributes are added to the resource and appear on every span, metric,
and log record exported by the service.
#### What happens if the collector is unavailable?
The OTLP HTTP exporters fail silently -- your application continues to handle
requests normally. Spans, metrics, and logs are buffered in memory and dropped
when the buffer is full. When the collector comes back online, new telemetry
data is exported normally. There is no automatic retry of dropped data.
#### How do I monitor multiple Elysia services in a single trace?
Each service needs its own `tracing.ts` with a unique `OTEL_SERVICE_NAME`.
Use `propagation.inject()` on outgoing requests and `propagation.extract()`
on incoming requests to link spans across services. The example in this
guide demonstrates this with the `elysia-articles` and `elysia-notify`
services.
### What's Next
#### Related Guides
- [Express Instrumentation](./express.md) - Most widely used Node.js framework
- [Fastify Instrumentation](./fastify.md) - Performance-focused alternative
- [Node.js Custom Instrumentation](../custom-instrumentation/javascript-node.md)
\- Manual spans and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
#### Scout Platform Features
- [Creating Alerts](../../../guides/creating-alerts-with-logx.md) - Set up
alerting for Elysia services
- [Dashboard Creation](../../../guides/create-your-first-dashboard.md) - Build
custom dashboards for Bun service metrics
#### Deployment and Operations
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) -
Local collector configuration
- [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md) -
Production deployment
### Complete Example
#### Project Structure
```text
elysia-postgres/
├── app/
│ ├── src/
│ │ ├── tracing.ts # OTel SDK initialization (preloaded)
│ │ ├── index.ts # Elysia app entry point
│ │ ├── logger.ts # Custom OTel logger with stdout mirror
│ │ ├── db.ts # Drizzle + pg pool
│ │ ├── schema.ts # Drizzle table schema
│ │ ├── notification.ts # Outgoing fetch with propagation.inject
│ │ └── routes/
│ │ ├── article.ts # Article CRUD with traced() wrapper
│ │ └── health.ts # Health check endpoint
│ ├── Dockerfile # oven/bun:1.3-alpine multi-stage
│ ├── package.json
│ └── tsconfig.json
├── notify/
│ ├── src/
│ │ ├── tracing.ts # OTel SDK for notify service
│ │ ├── index.ts # Notify service with propagation.extract
│ │ └── logger.ts # Shared logger pattern
│ ├── Dockerfile
│ └── package.json
├── config/
│ └── otel-config.yaml # Scout Collector configuration
├── db/
│ └── init.sql # PostgreSQL schema
├── compose.yml # Full development stack
└── README.md
```
#### Running the Example
```bash showLineNumbers
git clone https://github.com/base-14/examples.git
cd examples/bun/elysia-postgres
docker compose up --build
```
#### Testing
```bash showLineNumbers
# Create an article
curl -X POST http://localhost:8080/api/articles \
-H "Content-Type: application/json" \
-d '{"title": "Test Article", "body": "Testing OpenTelemetry with Elysia on Bun"}'
# List articles
curl http://localhost:8080/api/articles
# Get a specific article
curl http://localhost:8080/api/articles/1
# Update an article
curl -X PUT http://localhost:8080/api/articles/1 \
-H "Content-Type: application/json" \
-d '{"title": "Updated Title"}'
# Delete an article
curl -X DELETE http://localhost:8080/api/articles/1
# Health check
curl http://localhost:8080/api/health
```
#### Dependencies
```json showLineNumbers title="app/package.json"
{
"name": "elysia-postgres-app",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "bun run --preload ./src/tracing.ts ./src/index.ts",
"dev": "bun run --watch --preload ./src/tracing.ts ./src/index.ts"
},
"dependencies": {
"elysia": "^1.4.28",
"drizzle-orm": "^0.45.2",
"pg": "^8.20.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.214.0",
"@opentelemetry/sdk-node": "^0.214.0",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@opentelemetry/sdk-logs": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/instrumentation-pg": "^0.66.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/semantic-conventions": "^1.40.0"
},
"devDependencies": {
"drizzle-kit": "^0.31.10",
"@types/pg": "^8.20.0",
"typescript": "^6.0.2"
}
}
```
#### GitHub Repository
For the complete working example, see the
[Elysia PostgreSQL Example](https://github.com/base-14/examples/tree/main/bun/elysia-postgres)
repository.
### References
- [Official OpenTelemetry Node.js Documentation](https://opentelemetry.io/docs/languages/js/)
- [Elysia Documentation](https://elysiajs.com/)
- [Bun Documentation](https://bun.sh/docs)
- [Drizzle ORM Documentation](https://orm.drizzle.team/)
- [@opentelemetry/instrumentation-pg](https://www.npmjs.com/package/@opentelemetry/instrumentation-pg)
- [OpenTelemetry Logs API](https://opentelemetry.io/docs/specs/otel/logs/)
---
## Express.js OpenTelemetry Instrumentation - Traces & Metrics Setup
Implement OpenTelemetry instrumentation for Express.js applications to enable
comprehensive application performance monitoring (APM), distributed tracing, and
observability. This guide shows you how to auto-instrument your Express.js
application to collect traces and metrics from HTTP requests, database queries,
Redis operations, background jobs, and WebSocket connections using the
OpenTelemetry Node.js SDK with minimal code changes.
Express is the most widely used Node.js web framework.
[Fastify](./fastify.md) is a performance-focused alternative, and
[NestJS](./nestjs.md) adds a structured, opinionated layer on top of it.
Express.js applications benefit from automatic instrumentation of the framework
itself, as well as popular libraries including MongoDB (Mongoose), Redis
(IORedis, which also captures BullMQ queue traffic), Socket.IO, and dozens of
commonly used Node.js components.
With OpenTelemetry, you can monitor production performance, debug slow requests,
trace distributed transactions across microservices, and identify database query
bottlenecks without significant code modifications. The async-native design of
Node.js works seamlessly with OpenTelemetry's context propagation, ensuring
accurate parent-child span relationships across async operations.
Whether you're implementing observability for the first time, migrating from
commercial APM solutions like DataDog or New Relic, or troubleshooting
performance issues in production, this guide provides production-ready
configurations and best practices for Express.js OpenTelemetry instrumentation.
You'll learn how to set up auto-instrumentation, configure custom spans for
business logic, optimize performance, and deploy with Docker.
:::tip TL;DR
Create a `telemetry.ts` file that initializes `NodeSDK` with
`getNodeAutoInstrumentations()` and import it as the very first line of your
entry point - this single step auto-instruments Express routes, MongoDB
(Mongoose), Redis, and HTTP clients. Set `OTEL_SERVICE_NAME` and
`OTEL_EXPORTER_OTLP_ENDPOINT` to point at your Scout collector, and use
`BatchSpanProcessor` in production to minimize request latency overhead.
:::
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Overview
This guide demonstrates how to:
- Set up OpenTelemetry instrumentation for Express.js applications
- Configure automatic request and response tracing for HTTP endpoints
- Instrument database operations with Mongoose auto-instrumentation
- Implement custom spans for business logic and external API calls
- Collect and export HTTP metrics using custom middleware
- Configure production-ready telemetry with BatchSpanProcessor
- Export telemetry data to base14 Scout via OTLP
- Deploy instrumented applications with Docker and Docker Compose
- Troubleshoot common instrumentation issues
- Optimize performance impact in production environments
### Who This Guide Is For
This documentation is designed for:
- **Express.js developers**: implementing observability and distributed tracing
for the first time in Node.js applications
- **DevOps engineers**: deploying Express.js applications with production
monitoring requirements and container orchestration
- **Engineering teams**: migrating from DataDog, New Relic, or other commercial
APM solutions to open-source observability
- **Backend developers**: debugging performance issues, N+1 queries, or async
operation bottlenecks in Express.js services
- **Platform teams**: standardizing observability across multiple Express.js
microservices with consistent instrumentation patterns
### Prerequisites
Before starting, ensure you have:
- **Node.js 24.0.0 or later** installed (Krypton LTS recommended, active until
April 2028)
- **Express 5.0.0 or later** installed in your project (5.0.1+ recommended for
latest security)
- **Scout Collector** configured and accessible from your application
- See [Docker Compose Setup](../../collector-setup/docker-compose-example.md)
for local development
- See [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md)
for production deployment
- **Basic understanding** of OpenTelemetry concepts (traces, spans, attributes)
- Access to npm for package installation
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version | Notes |
| ------------------------- | --------------- | ------------------- | -------------------------------------- |
| **Node.js** | 24.0.0 | 24.x LTS | Krypton - Active until April 2028 |
| **Express** | 5.0.0 | 5.0.1+ | Latest v5 with improved security |
| **TypeScript** (optional) | 5.0.0 | 5.7.2+ | For type safety |
| **OpenTelemetry SDK** | 0.200.0 | 0.208+ | Core SDK for traces and metrics |
| **Mongoose** (optional) | 8.0.0 | 8.20.1+ | For MongoDB (**v9 not yet supported**) |
| **IORedis** (optional) | 5.0.0 | 5.4.2+ | For Redis instrumentation |
#### Supported Libraries
OpenTelemetry automatically instruments these commonly used libraries:
- **Web frameworks**: Express, HTTP/HTTPS
- **Databases**: MongoDB (Mongoose), PostgreSQL, MySQL
- **Caching**: Redis (IORedis), Memcached
- **Job Queues**: BullMQ
- **Real-time**: Socket.IO
- **HTTP Clients**: axios, node-fetch, http/https
### Installation
#### Core Packages
Install the required OpenTelemetry packages for Express.js instrumentation:
```bash
npm install @opentelemetry/api
npm install @opentelemetry/sdk-node
npm install @opentelemetry/auto-instrumentations-node
npm install @opentelemetry/exporter-trace-otlp-http
npm install @opentelemetry/exporter-metrics-otlp-http
npm install @opentelemetry/resources
npm install @opentelemetry/semantic-conventions
```
#### Optional Instrumentation Libraries
Add these packages to instrument additional components:
```bash
# MongoDB/Mongoose instrumentation
npm install @opentelemetry/instrumentation-mongoose
# Redis instrumentation
npm install @opentelemetry/instrumentation-ioredis
# Winston logging instrumentation
npm install @opentelemetry/instrumentation-winston
```
#### Complete Requirements File
For production applications, add all dependencies to `package.json`:
```json title="package.json" showLineNumbers
{
"dependencies": {
"express": "^5.0.1",
"mongoose": "^8.20.1",
"ioredis": "^5.4.2",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.208.0",
"@opentelemetry/sdk-node": "^0.208.0",
"@opentelemetry/sdk-logs": "^0.208.0",
"@opentelemetry/auto-instrumentations-node": "^0.67.2",
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.208.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
"@opentelemetry/instrumentation-express": "^0.57.0",
"@opentelemetry/instrumentation-http": "^0.208.0",
"@opentelemetry/instrumentation-mongodb": "^0.61.0",
"@opentelemetry/instrumentation-mongoose": "^0.55.0",
"@opentelemetry/instrumentation-ioredis": "^0.56.0",
"@opentelemetry/instrumentation-winston": "^0.53.0",
"@opentelemetry/resources": "^2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
}
}
```
> For a complete list of dependencies including security, validation, and other
> libraries, see the
> [complete example](https://github.com/base-14/examples/tree/main/nodejs/express-typescript-mongodb/package.json).
Then install all dependencies:
```bash
npm install
```
### Configuration
Express.js OpenTelemetry instrumentation can be configured in multiple ways
depending on your application architecture and deployment requirements. This
section covers different setup approaches and advanced configuration options.
#### Setup Approaches
Choose the initialization method that best fits your application architecture:
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
##### Separate Telemetry Module (Recommended)
For better code organization and reusability, create a dedicated telemetry
module. This approach is recommended for production applications.
```typescript title="src/telemetry.ts" showLineNumbers
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { MongooseInstrumentation } from "@opentelemetry/instrumentation-mongoose";
import { WinstonInstrumentation } from "@opentelemetry/instrumentation-winston";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { resourceFromAttributes } from "@opentelemetry/resources";
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
export function setupTelemetry(): NodeSDK {
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || "express-app",
[ATTR_SERVICE_VERSION]: process.env.APP_VERSION || "1.0.0",
"deployment.environment": process.env.NODE_ENV || "development",
"environment": process.env.NODE_ENV || "development",
});
const endpoint =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318";
const sdk = new NodeSDK({
resource,
traceExporter: new OTLPTraceExporter({
url: `${endpoint}/v1/traces`,
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: `${endpoint}/v1/metrics`,
}),
exportIntervalMillis: 60000,
}),
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-fs": {
enabled: false,
},
"@opentelemetry/instrumentation-express": {
enabled: true,
},
"@opentelemetry/instrumentation-http": {
enabled: true,
},
"@opentelemetry/instrumentation-ioredis": {
enabled: true,
},
}),
new MongooseInstrumentation({
requireParentSpan: false,
}),
new WinstonInstrumentation(),
],
});
sdk.start();
console.log("✅ OpenTelemetry SDK initialized");
process.on("SIGTERM", () => {
sdk
.shutdown()
.then(() => console.log("OpenTelemetry SDK shut down successfully"))
.catch((error) =>
console.error("Error shutting down OpenTelemetry SDK", error),
)
.finally(() => process.exit(0));
});
return sdk;
}
```
This configuration automatically captures:
- HTTP request method, path, status code, and duration
- Request and response headers (configurable)
- Query parameters and path parameters
- MongoDB queries (via Mongoose)
- Redis operations (via IORedis)
- Error and exception information
```mdx-code-block
```
##### Instrumentation Entry Point Pattern
Create a separate instrumentation file that Node.js loads before your main
application using the `--import` flag. This ensures OpenTelemetry is initialized
before any application code runs.
```typescript title="src/instrumentation.ts" showLineNumbers
import { setupTelemetry } from "./telemetry.js";
setupTelemetry();
```
Then use the Node.js `--import` flag to load instrumentation first:
```bash
node --import ./dist/instrumentation.js dist/index.js
```
In `package.json` scripts:
```json title="package.json" showLineNumbers
{
"scripts": {
"build": "tsc",
"start": "node --import ./dist/instrumentation.js dist/index.js"
}
}
```
**Main application file**:
```typescript title="src/index.ts" showLineNumbers
import express from "express";
import { connectDatabase } from "./database.js";
// Instrumentation is already loaded via --import flag
const app = express();
app.use(express.json());
app.get("/", (req, res) => {
res.json({ message: "Hello World" });
});
const PORT = process.env.APP_PORT || 3000;
async function startServer() {
await connectDatabase();
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
}
startServer();
```
```mdx-code-block
```
##### Environment Variables Configuration
Configure OpenTelemetry entirely through environment variables for
container-friendly deployments:
```bash title=".env" showLineNumbers
# Application
NODE_ENV=development
APP_PORT=3000
APP_VERSION=1.0.0
# OpenTelemetry
OTEL_SERVICE_NAME=express-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
# MongoDB
MONGODB_URI=mongodb://mongo:27017/express-app
# Redis
REDIS_URL=redis://redis:6379
```
Update your telemetry module to use environment variables:
```typescript title="src/telemetry.ts" showLineNumbers
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || "express-app",
[ATTR_SERVICE_VERSION]: process.env.APP_VERSION || "1.0.0",
"deployment.environment": process.env.NODE_ENV || "development",
"environment": process.env.NODE_ENV || "development",
});
const endpoint =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318";
```
```mdx-code-block
```
##### Selective Instrumentation
Control which components are instrumented by enabling or disabling specific
auto-instrumentations:
```typescript title="src/telemetry.ts" showLineNumbers
const sdk = new NodeSDK({
resource,
traceExporter: new OTLPTraceExporter({
url: `${endpoint}/v1/traces`,
}),
instrumentations: [
getNodeAutoInstrumentations({
// Disable filesystem instrumentation (reduces overhead)
"@opentelemetry/instrumentation-fs": {
enabled: false,
},
// Enable Express with custom configuration
"@opentelemetry/instrumentation-express": {
enabled: true,
ignoreLayersType: ["request_handler"], // Skip specific middleware
},
// Enable HTTP with endpoint exclusions
"@opentelemetry/instrumentation-http": {
enabled: true,
ignoreIncomingPaths: ["/health", "/metrics", "/favicon.ico"],
},
// Enable MongoDB
"@opentelemetry/instrumentation-mongodb": {
enabled: true,
},
// Enable Redis
"@opentelemetry/instrumentation-ioredis": {
enabled: true,
},
}),
],
});
```
```mdx-code-block
```
##### Scout Collector Integration
Configure direct integration with base14 Scout collector:
```typescript title="src/telemetry.ts" showLineNumbers
const sdk = new NodeSDK({
resource,
traceExporter: new OTLPTraceExporter({
url: `${process.env.SCOUT_ENDPOINT}/v1/traces`,
headers: {
Authorization: `Bearer ${process.env.SCOUT_TOKEN}`,
},
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: `${process.env.SCOUT_ENDPOINT}/v1/metrics`,
headers: {
Authorization: `Bearer ${process.env.SCOUT_TOKEN}`,
},
}),
exportIntervalMillis: 60000,
}),
// ... rest of configuration
});
```
Environment variables for Scout:
```bash title=".env" showLineNumbers
SCOUT_ENDPOINT=https://your-tenant.base14.io:4318
SCOUT_TOKEN=your_bearer_token
```
```mdx-code-block
```
### Traces
Traces provide the complete picture of what happens when a request flows through
your Express.js application. They capture the entire lifecycle from the incoming
HTTP request, through your business logic, database queries, external API calls,
and finally the response sent back to the client.
#### Automatic Trace Collection
Once instrumented, Express.js automatically captures detailed trace information
for every request:
**Captured Information:**
- HTTP method, path, and status code
- Request duration and timing breakdown
- Request and response headers (configurable)
- Query parameters and path parameters
- MongoDB queries (operation, collection, execution time)
- Redis operations (command, key, execution time)
- WebSocket connections (via Socket.IO)
- Error and exception stack traces
- Distributed trace context propagation (W3C Trace Context)
**Trace Hierarchy:**
```text
HTTP Request Span (root)
├── Express Router Span
│ ├── Auth Middleware Span
│ ├── Route Handler Span
│ │ ├── MongoDB Query Span (Mongoose)
│ │ ├── Redis GET Span (IORedis)
│ │ ├── BullMQ Job Enqueue Span
│ │ └── Custom Business Logic Span
│ └── Response Middleware Span
└── HTTP Response Span
```
#### Key Tracing Features
- **Automatic HTTP tracking**: Every endpoint is automatically traced with no
code changes
- **Error capturing**: Exceptions are automatically recorded with full stack
traces
- **Context propagation**: Distributed traces work across microservices using
W3C Trace Context headers
- **Custom attributes**: Add business-specific metadata to spans (covered in
Custom Instrumentation section)
- **Async support**: Full support for async/await patterns with correct context
preservation
> View traces in your base14 Scout dashboard to analyze request flows and
> identify bottlenecks.
##### Reference
[Official Traces Documentation](https://opentelemetry.io/docs/concepts/signals/traces/)
### Metrics
OpenTelemetry metrics capture runtime measurements of your Express.js
application including HTTP request counts, latencies, response status codes, and
custom business metrics. Unlike traces that show individual request flows,
metrics aggregate data over time for monitoring trends and alerting.
#### Custom Metrics
Create custom metrics to track business operations:
```typescript title="src/utils/metrics.ts" showLineNumbers
import { metrics } from "@opentelemetry/api";
const meter = metrics.getMeter("express-app", "1.0.0");
export const articleMetrics = {
created: meter.createCounter("articles.created.total", {
description: "Total number of articles created",
unit: "1",
}),
published: meter.createCounter("articles.published.total", {
description: "Total number of articles published",
unit: "1",
}),
publishDuration: meter.createHistogram("article.publish.duration", {
description: "Duration of article publish job processing",
unit: "ms",
}),
contentSize: meter.createHistogram("article.content.size", {
description: "Size of article content in characters",
unit: "characters",
}),
favorited: meter.createCounter("articles.favorited.total", {
description: "Total number of article favorites",
unit: "1",
}),
};
export const authMetrics = {
loginSuccess: meter.createCounter("users.login.success.total", {
description: "Total number of successful login attempts",
unit: "1",
}),
loginFailed: meter.createCounter("users.login.failed.total", {
description: "Total number of failed login attempts",
unit: "1",
}),
};
export const jobMetrics = {
enqueued: meter.createCounter("jobs.enqueued.total", {
description: "Total number of jobs enqueued",
unit: "1",
}),
completed: meter.createCounter("jobs.completed.total", {
description: "Total number of jobs completed successfully",
unit: "1",
}),
processingTime: meter.createHistogram("jobs.processing.duration", {
description: "Duration of job processing",
unit: "ms",
}),
};
```
**Usage in controllers**:
```typescript title="src/controllers/article.controller.ts" showLineNumbers
import { articleMetrics } from "../utils/metrics.js";
export async function createArticle(req, res) {
const article = await Article.create(req.body);
articleMetrics.created.add(1);
articleMetrics.contentSize.record(article.content.length);
res.status(201).json(article);
}
```
#### Available Metrics
Once configured, these metrics are automatically collected:
| Metric Name | Type | Description | Attributes |
| --------------------------- | --------- | ------------------------ | ---------- |
| `articles.created.total` | Counter | Total articles created | - |
| `articles.published.total` | Counter | Total articles published | - |
| `articles.favorited.total` | Counter | Total favorites | - |
| `users.login.success.total` | Counter | Successful logins | - |
| `users.login.failed.total` | Counter | Failed login attempts | - |
| `jobs.enqueued.total` | Counter | Jobs enqueued | job.type |
| `jobs.processing.duration` | Histogram | Job processing time | job.type |
| `article.publish.duration` | Histogram | Publish duration | - |
| `article.content.size` | Histogram | Content size | - |
> View these metrics in base14 Scout to create dashboards, set up alerts, and
> monitor application health.
### Production Configuration
Production environments require careful configuration of OpenTelemetry to
balance observability needs with performance and reliability. This section
covers production-ready patterns.
#### BatchSpanProcessor Configuration
Configure BatchSpanProcessor parameters for optimal performance:
```typescript title="src/telemetry.ts" showLineNumbers
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
const batchProcessor = new BatchSpanProcessor(
new OTLPTraceExporter({
url: "http://otel-collector:4318/v1/traces",
}),
{
maxQueueSize: 2048, // Maximum spans in queue
scheduledDelayMillis: 5000, // Export every 5 seconds
maxExportBatchSize: 512, // Maximum spans per export
exportTimeoutMillis: 30000, // Timeout for export operation
},
);
```
#### Resource Attributes for Production
Add comprehensive resource attributes to identify your service:
```typescript title="src/telemetry.ts" showLineNumbers
import os from "os";
import { resourceFromAttributes } from "@opentelemetry/resources";
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
const resource = resourceFromAttributes({
// Service identification
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || "express-app",
[ATTR_SERVICE_VERSION]: process.env.APP_VERSION || "1.0.0",
// Deployment information
"deployment.environment": process.env.NODE_ENV || "development",
"environment": process.env.NODE_ENV || "development",
"deployment.region": process.env.AWS_REGION || "us-east-1",
// Instance identification
"service.instance.id": os.hostname(),
"host.name": os.hostname(),
"host.type": "container",
// Container information (if applicable)
"container.id": process.env.HOSTNAME || "",
"container.name": process.env.CONTAINER_NAME || "",
// Kubernetes information (if applicable)
"k8s.namespace.name": process.env.K8S_NAMESPACE || "",
"k8s.pod.name": process.env.K8S_POD_NAME || "",
});
```
#### Environment-Based Configuration
Use environment variables to configure telemetry without code changes:
```typescript title="src/config.ts" showLineNumbers
export const config = {
app: {
env: process.env.NODE_ENV || "development",
port: parseInt(process.env.APP_PORT || "3000", 10),
version: process.env.APP_VERSION || "1.0.0",
},
otel: {
serviceName: process.env.OTEL_SERVICE_NAME || "express-app",
endpoint:
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318",
},
mongodb: {
uri: process.env.MONGODB_URI || "mongodb://localhost:27017/express-app",
},
redis: {
url: process.env.REDIS_URL || "redis://localhost:6379",
},
};
```
#### Docker Compose Configuration
Example `compose.yml` for production-like deployment:
```yaml title="compose.yml" showLineNumbers
services:
app:
build: .
ports:
- "3000:3000"
environment:
NODE_ENV: development
APP_VERSION: "1.0.0"
OTEL_SERVICE_NAME: express-app
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
MONGODB_URI: mongodb://mongo:27017/express-app
REDIS_URL: redis://redis:6379
depends_on:
- mongo
- redis
- otel-collector
mongo:
image: mongo:8.0-noble
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
redis:
image: redis:8-alpine
ports:
- "6379:6379"
otel-collector:
image: otel/opentelemetry-collector-contrib:0.115.1
command: ["--config=/etc/otelcol-contrib/config.yaml"]
volumes:
- ./config/otel-config.yaml:/etc/otelcol-contrib/config.yaml
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
- "55679:55679" # zpages for debugging
volumes:
mongo_data:
```
> For a production-ready compose configuration with health checks, networks, and
> env_file support, see the
> [complete example](https://github.com/base-14/examples/tree/main/nodejs/express-typescript-mongodb/compose.yml).
#### Environment Variables Template
Create a `.env.example` file for your team:
```bash title=".env.example" showLineNumbers
# Application
NODE_ENV=development
APP_PORT=3000
APP_VERSION=1.0.0
# OpenTelemetry
OTEL_SERVICE_NAME=express-mongodb-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development,environment=development,service.version=1.0.0
# MongoDB
MONGODB_URI=mongodb://mongo:27017/express-app
# Redis (for BullMQ job queue)
REDIS_URL=redis://redis:6379
# JWT Authentication
JWT_SECRET=your-secret-key-change-in-development
JWT_EXPIRES_IN=7d
# Security
CORS_ORIGIN=*
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX=100
# base14 Scout Configuration (Required - set these via environment or .env.local)
SCOUT_ENDPOINT=https://your-tenant.base14.io:4318
SCOUT_CLIENT_ID=your_client_id
SCOUT_CLIENT_SECRET=your_client_secret
SCOUT_TOKEN_URL=https://your-tenant.base14.io/oauth/token
```
#### Dockerfile with OpenTelemetry
Build a production-ready Docker image:
```dockerfile title="Dockerfile" showLineNumbers
FROM node:24-alpine AS builder
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package*.json tsconfig.json ./
RUN npm install
COPY src ./src
RUN npm run build
RUN npm prune --production
# Runtime stage
FROM node:24-alpine
WORKDIR /app
RUN apk add --no-cache curl
RUN addgroup -S appuser && \
adduser -D -S -G appuser appuser
COPY --from=builder --chown=appuser:appuser /app/dist ./dist
COPY --from=builder --chown=appuser:appuser /app/node_modules ./node_modules
COPY --chown=appuser:appuser package*.json ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=5m --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
CMD ["node", "--import", "./dist/instrumentation.js", "dist/index.js"]
```
#### Graceful Shutdown
Implement graceful shutdown to flush pending spans:
```typescript title="src/index.ts" showLineNumbers
import http from "http";
import { setupTelemetry } from "./telemetry.js";
import { createApp } from "./app.js";
import { connectDatabase, disconnectDatabase } from "./database.js";
// Initialize telemetry FIRST
const sdk = setupTelemetry();
const app = createApp();
const server = http.createServer(app);
async function gracefulShutdown(signal: string) {
console.log(`${signal} received, shutting down gracefully...`);
// 1. Stop accepting new connections
server.close(() => {
console.log("HTTP server closed");
});
// 2. Shutdown OpenTelemetry SDK (flush pending spans)
await sdk.shutdown();
console.log("OpenTelemetry SDK shut down");
// 3. Disconnect from database
await disconnectDatabase();
console.log("Database disconnected");
process.exit(0);
}
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
const PORT = process.env.APP_PORT || 3000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
```
### Framework-Specific Features
Express.js integration with OpenTelemetry automatically instruments several
framework components and commonly used libraries. This section covers automatic
instrumentation for databases, caching, background jobs, and WebSockets.
#### MongoDB/Mongoose Database Instrumentation
OpenTelemetry automatically instruments Mongoose database queries, providing
detailed visibility into database operations.
**Installation:**
```bash
npm install @opentelemetry/instrumentation-mongoose
```
**Automatic Instrumentation:**
```typescript title="src/telemetry.ts" showLineNumbers
import { MongooseInstrumentation } from "@opentelemetry/instrumentation-mongoose";
const sdk = new NodeSDK({
instrumentations: [
new MongooseInstrumentation({
requireParentSpan: false, // Create spans even without parent
}),
],
});
```
This automatically captures:
- MongoDB operation (find, insertOne, updateOne, deleteOne)
- Collection name
- Query execution time
- Database connection details
- N+1 query detection (via span hierarchy)
**Example traced query:**
```text
HTTP POST /api/v1/articles
└── article.create (custom span)
└── mongodb.insertOne (auto-instrumented)
Collection: articles
Duration: 15ms
```
> **Note**: Mongoose 9.x is not yet supported by the instrumentation package.
> Use Mongoose 8.x (8.20.1+ recommended).
#### Redis/IORedis Instrumentation
Automatically trace Redis operations:
```typescript title="src/telemetry.ts" showLineNumbers
const sdk = new NodeSDK({
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-ioredis": {
enabled: true,
},
}),
],
});
```
This captures:
- Redis command (GET, SET, HGET, LPUSH, etc.)
- Key names
- Command execution time
- Connection details
#### Background Jobs (BullMQ) with Trace Propagation
Trace background jobs with context propagation:
```typescript title="src/jobs/publishArticleJob.ts" showLineNumbers
import { Queue, Worker, Job } from "bullmq";
import { trace } from "@opentelemetry/api";
// Create job queue
const publishQueue = new Queue("article-publish", {
connection: { url: process.env.REDIS_URL },
});
// Enqueue job with trace context
export async function enqueuePublishJob(articleId: string) {
await publishQueue.add("publish-article", {
articleId,
// Trace context is automatically propagated by IORedis instrumentation
});
}
// Job worker with tracing
const worker = new Worker("article-publish", async (job: Job) => {
const tracer = trace.getTracer("article-job");
const span = tracer.startSpan("article.publish");
try {
span.setAttributes({
"article.id": job.data.articleId,
"job.id": job.id,
});
// Publish article logic
await Article.findByIdAndUpdate(job.data.articleId, {
published: true,
publishedAt: new Date(),
});
span.addEvent("article_published");
return { success: true };
} catch (error) {
span.recordException(error as Error);
throw error;
} finally {
span.end();
}
});
```
#### WebSocket (Socket.IO) Tracing
Trace WebSocket connections and events:
```typescript title="src/socket.ts" showLineNumbers
import { Server } from "socket.io";
import { trace } from "@opentelemetry/api";
import http from "http";
const tracer = trace.getTracer("socket-io");
export function setupWebSocket(server: http.Server) {
const io = new Server(server, {
cors: { origin: "*" },
});
io.on("connection", (socket) => {
const span = tracer.startSpan("websocket.connection");
span.setAttributes({
"socket.id": socket.id,
"socket.transport": socket.conn.transport.name,
});
span.addEvent("client_connected", {
"socket.id": socket.id,
});
socket.on("subscribe:articles", () => {
socket.join("articles");
span.addEvent("subscribed_to_articles");
socket.emit("subscribed", { channel: "articles" });
});
socket.on("disconnect", () => {
span.addEvent("client_disconnected");
span.end();
});
});
return io;
}
```
### Custom Instrumentation
While auto-instrumentation captures HTTP requests and database queries, custom
instrumentation lets you trace business logic, add contextual attributes, and
instrument specific operations.
#### Creating Custom Spans with Utility Function
Create a reusable utility for consistent span management:
```typescript title="src/utils/tracing.ts" showLineNumbers
import {
trace,
SpanStatusCode,
context as otelContext,
type Span,
} from "@opentelemetry/api";
type AsyncSpanFn = (span: Span) => Promise;
export function withSpan(
tracerName: string,
spanName: string,
fn: AsyncSpanFn,
): Promise {
const tracer = trace.getTracer(tracerName);
const span = tracer.startSpan(spanName);
return otelContext.with(
trace.setSpan(otelContext.active(), span),
async () => {
try {
const result = await fn(span);
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (error) {
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: (error as Error).message,
});
throw error;
} finally {
span.end();
}
},
);
}
```
**Usage in controller**:
```typescript title="src/controllers/article.controller.ts" showLineNumbers
import { withSpan } from "../utils/tracing.js";
export async function createArticle(req, res) {
return withSpan("article-controller", "article.create", async (span) => {
const article = await Article.create(req.body);
span.setAttributes({
"article.id": article._id.toString(),
"article.title": article.title,
"article.published": article.published,
});
span.addEvent("article_created", {
"article.id": article._id.toString(),
});
res.status(201).json(article);
});
}
```
#### Adding Custom Attributes to Active Span
Enrich the automatically created span with custom data:
```typescript title="src/middleware/auth.middleware.ts" showLineNumbers
import { trace, SpanStatusCode } from "@opentelemetry/api";
export async function authenticate(req, res, next) {
const currentSpan = trace.getActiveSpan();
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
if (currentSpan) {
currentSpan.addEvent("auth_failed", { reason: "missing_token" });
}
return res.status(401).json({ error: "Authentication required" });
}
const token = authHeader.substring(7);
const payload = verifyToken(token);
const user = await User.findById(payload.userId);
if (!user) {
if (currentSpan) {
currentSpan.addEvent("auth_failed", { reason: "user_not_found" });
}
return res.status(401).json({ error: "User not found" });
}
req.user = user;
// Add user context to active span
if (currentSpan) {
currentSpan.setAttributes({
"user.id": user._id.toString(),
"user.email": user.email,
"user.role": user.role,
});
currentSpan.addEvent("auth_success");
}
next();
} catch (error) {
if (currentSpan) {
currentSpan.recordException(error as Error);
currentSpan.setStatus({
code: SpanStatusCode.ERROR,
message: (error as Error).message,
});
}
next(error);
}
}
```
#### Error Handling and Status
Record errors and set span status appropriately:
```typescript title="src/controllers/article.controller.ts" showLineNumbers
import { trace, SpanStatusCode } from "@opentelemetry/api";
export async function getArticle(req, res) {
const tracer = trace.getTracer("article-controller");
const span = tracer.startSpan("article.get");
try {
const article = await Article.findById(req.params.id);
if (!article) {
span.setAttributes({
"article.id": req.params.id,
"article.found": false,
});
span.setStatus({
code: SpanStatusCode.ERROR,
message: "Article not found",
});
span.end();
return res.status(404).json({ error: "Article not found" });
}
span.setAttributes({
"article.id": article._id.toString(),
"article.title": article.title,
"article.found": true,
});
span.setStatus({ code: SpanStatusCode.OK });
res.json(article);
} catch (error) {
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: (error as Error).message,
});
throw error;
} finally {
span.end();
}
}
```
#### Span Events for Business Operations
Add timestamped events to track important operations:
```typescript title="src/controllers/favorite.controller.ts" showLineNumbers
import { withSpan } from "../utils/tracing.js";
export async function favoriteArticle(req, res) {
return withSpan("favorite-controller", "article.favorite", async (span) => {
const article = await Article.findById(req.params.id);
span.setAttributes({
"article.id": article._id.toString(),
"user.id": req.user._id.toString(),
});
// Add event for favorite action
span.addEvent("article_favorited", {
"article.id": article._id.toString(),
"article.title": article.title,
"user.id": req.user._id.toString(),
timestamp: new Date().toISOString(),
});
// Update favorites count
article.favorites.push(req.user._id);
await article.save();
span.setAttributes({
"article.favorites_count": article.favorites.length,
});
res.json(article);
});
}
```
#### Semantic Conventions
Use OpenTelemetry semantic conventions for consistent attribute naming:
```typescript title="src/middleware/request-logger.middleware.ts" showLineNumbers
import { trace } from "@opentelemetry/api";
import {
ATTR_HTTP_REQUEST_METHOD,
ATTR_HTTP_ROUTE,
ATTR_HTTP_RESPONSE_STATUS_CODE,
ATTR_USER_AGENT_ORIGINAL,
} from "@opentelemetry/semantic-conventions";
export function requestLogger(req, res, next) {
const currentSpan = trace.getActiveSpan();
if (currentSpan && currentSpan.isRecording()) {
// Use semantic convention constants
currentSpan.setAttributes({
[ATTR_HTTP_REQUEST_METHOD]: req.method,
[ATTR_HTTP_ROUTE]: req.route?.path || req.path,
[ATTR_USER_AGENT_ORIGINAL]: req.get("user-agent") || "",
"http.request.body.size": JSON.stringify(req.body).length,
});
res.on("finish", () => {
currentSpan.setAttributes({
[ATTR_HTTP_RESPONSE_STATUS_CODE]: res.statusCode,
"http.response.body.size": res.get("content-length") || 0,
});
});
}
next();
}
```
### Running Your Application
#### Development Mode
Run with console output for local development:
```bash
# Set environment to development
export NODE_ENV=development
export OTEL_SERVICE_NAME=express-app
# Run with ts-node (TypeScript)
npm run dev
# or
ts-node src/index.ts
```
**package.json scripts**:
```json title="package.json" showLineNumbers
{
"scripts": {
"dev": "ts-node src/index.ts",
"build": "tsc",
"start": "node --import ./dist/instrumentation.js dist/index.js"
}
}
```
#### Production Mode
Run with OTLP exporter pointing to Scout Collector:
```bash
# Build TypeScript
npm run build
# Set production environment variables
export NODE_ENV=development
export OTEL_SERVICE_NAME=express-app
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
export APP_VERSION=1.0.0
# Run with instrumentation
npm start
# Runs: node --import ./dist/instrumentation.js dist/index.js
```
#### Docker Deployment
Build and run with Docker:
```bash
# Build image
docker build -t express-app:latest .
# Run container
docker run -d \
--name express-app \
-p 3000:3000 \
-e NODE_ENV=development \
-e OTEL_SERVICE_NAME=express-app \
-e OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
-e MONGODB_URI=mongodb://mongo:27017/express-app \
-e REDIS_URL=redis://redis:6379 \
express-app:latest
```
Or use Docker Compose:
```bash
# Start all services (app, MongoDB, Redis, OTel Collector)
docker compose up --build
# View logs
docker compose logs -f app
# Stop services
docker compose down
```
### Troubleshooting
#### Verifying Instrumentation
Create a test endpoint to verify OpenTelemetry is working:
```typescript title="src/routes/health.ts" showLineNumbers
import { trace } from "@opentelemetry/api";
router.get("/health", (req, res) => {
const currentSpan = trace.getActiveSpan();
if (currentSpan && currentSpan.isRecording()) {
const spanContext = currentSpan.spanContext();
return res.json({
status: "healthy",
tracing: "enabled",
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
});
}
return res.json({
status: "healthy",
tracing: "disabled",
});
});
```
#### Common Issues
##### Issue: No traces appearing in Scout
**Solutions:**
1. **Verify OTLP endpoint is accessible**:
```bash
curl http://otel-collector:4318/v1/traces
# Should return 405 Method Not Allowed (endpoint exists)
```
2. **Check telemetry initialization happens BEFORE app creation**:
```typescript
// ❌ WRONG ORDER
import { createApp } from "./app.js";
import { setupTelemetry } from "./telemetry.js";
setupTelemetry(); // Too late!
// ✅ CORRECT ORDER
import { setupTelemetry } from "./telemetry.js";
setupTelemetry(); // First!
import { createApp } from "./app.js";
```
3. **Enable console exporter to verify spans are being created**:
```typescript
import { ConsoleSpanExporter } from "@opentelemetry/sdk-trace-base";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
const sdk = new NodeSDK({
spanProcessors: [new BatchSpanProcessor(new ConsoleSpanExporter())],
});
```
##### Issue: Redis spans missing after upgrading ioredis
`@opentelemetry/instrumentation-ioredis` patches ioredis versions
`>=2.0.0 <6` only. On ioredis 6.x the module is not patched, so Redis
client spans stop appearing while the application continues to work.
Stay on ioredis 5.x until the instrumentation supports ioredis 6.
##### Issue: Database queries not traced
**Solutions:**
1. **Ensure Mongoose instrumentation is registered BEFORE creating connection**:
```typescript
// ✅ CORRECT ORDER
const sdk = new NodeSDK({
instrumentations: [new MongooseInstrumentation()],
});
sdk.start();
await mongoose.connect(MONGODB_URI);
```
2. **Check Mongoose version compatibility**:
```bash
npm list mongoose
# Must be 8.x (9.x not yet supported)
```
##### Issue: High memory usage or performance degradation
**Solutions:**
1. **Configure BatchSpanProcessor with appropriate limits**:
```typescript
const batchProcessor = new BatchSpanProcessor(exporter, {
maxQueueSize: 2048, // Reduce if memory is constrained
scheduledDelayMillis: 5000, // Increase to batch more spans
maxExportBatchSize: 512,
});
```
2. **Exclude high-volume endpoints**:
```typescript
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-http": {
ignoreIncomingPaths: ["/health", "/metrics", "/favicon.ico"],
},
}),
];
```
##### Issue: Background jobs losing trace context
**Solutions:**
1. **Verify IORedis instrumentation is enabled** (BullMQ uses Redis):
```typescript
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-ioredis": {
enabled: true,
},
}),
];
```
2. **Use manual span creation in job workers**:
```typescript
const worker = new Worker("queue-name", async (job) => {
const tracer = trace.getTracer("job-worker");
const span = tracer.startSpan("process-job");
try {
// Job logic with proper tracing
} finally {
span.end();
}
});
```
### Security Considerations
#### Sensitive Data in Spans
Avoid capturing sensitive information in span attributes:
**Bad Example (DON'T DO THIS)**:
```typescript
// ❌ NEVER capture sensitive data
span.setAttribute("user.password", password);
span.setAttribute("credit_card.number", cardNumber);
span.setAttribute("user.ssn", ssn);
span.setAttribute("api_key", apiKey);
span.setAttribute("auth_token", token);
```
**Good Example (DO THIS INSTEAD)**:
```typescript
// ✅ GOOD - Reference IDs and metadata only
span.setAttribute("user.id", user.id);
span.setAttribute("payment.method", "credit_card");
span.setAttribute("payment.last4", cardNumber.slice(-4));
span.setAttribute("user.email_domain", email.split("@")[1]);
span.setAttribute("api_key.prefix", apiKey.substring(0, 8));
```
#### HTTP Header Filtering
Filter sensitive headers from traces:
```typescript title="src/middleware/sanitize-headers.ts" showLineNumbers
import { trace } from "@opentelemetry/api";
export function sanitizeHeaders(req) {
const currentSpan = trace.getActiveSpan();
if (currentSpan) {
// ✅ Safe headers to capture
const safeHeaders = {
"content-type": req.get("content-type"),
"user-agent": req.get("user-agent"),
accept: req.get("accept"),
"x-request-id": req.get("x-request-id"),
};
currentSpan.setAttributes({
"http.request.headers": JSON.stringify(safeHeaders),
});
// ❌ NEVER capture these headers:
// - authorization
// - cookie
// - x-api-key
// - proxy-authorization
}
}
```
#### Query Parameter Sanitization
Sanitize query parameters before adding to spans:
```typescript title="src/utils/sanitize.ts" showLineNumbers
export function sanitizeQueryParams(
params: Record,
): Record {
const sensitiveKeys = ["password", "token", "api_key", "secret", "ssn"];
const sanitized: Record = {};
for (const [key, value] of Object.entries(params)) {
if (sensitiveKeys.some((sk) => key.toLowerCase().includes(sk))) {
sanitized[key] = "[REDACTED]";
} else {
sanitized[key] = value;
}
}
return sanitized;
}
// Usage
const currentSpan = trace.getActiveSpan();
if (currentSpan) {
currentSpan.setAttribute(
"http.query",
JSON.stringify(sanitizeQueryParams(req.query)),
);
}
```
#### Environment Variable Security
Never commit sensitive values to version control:
```bash title=".env" showLineNumbers
# ❌ BAD - Don't commit .env files with real secrets
SECRET_KEY=actual-secret-key-here
MONGODB_URI=mongodb://admin:password123@mongo:27017/db
JWT_SECRET=my-super-secret-jwt-key
```
In production, use environment-specific secrets management:
- AWS Secrets Manager
- HashiCorp Vault
- Kubernetes Secrets
- Azure Key Vault
- Google Cloud Secret Manager
### Performance Considerations
#### Expected Performance Impact
OpenTelemetry instrumentation adds minimal overhead when properly configured:
| Metric | Impact | Notes |
| ----------- | -------------------- | ------------------------------------------------- |
| **Latency** | +0.5-2ms per request | Mostly from span creation and context propagation |
| **CPU** | +2-5% | Primarily during span export operations |
| **Memory** | +10-50MB | BatchSpanProcessor queue and SDK overhead |
| **Network** | +1-5KB per trace | OTLP HTTP with gzip compression |
**Impact Factors**:
- Number of spans per request
- Span attribute size and count
- Export frequency (BatchSpanProcessor schedule)
- Number of active requests
- Enabled instrumentations
#### Optimization Strategies
##### 1. Use BatchSpanProcessor in Production
Always use `BatchSpanProcessor` (never `SimpleSpanProcessor`) for production:
```typescript title="src/telemetry.ts" showLineNumbers
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
// ✅ GOOD - Batches spans for efficient export
const batchProcessor = new BatchSpanProcessor(exporter, {
maxQueueSize: 2048,
scheduledDelayMillis: 5000,
maxExportBatchSize: 512,
});
// ❌ BAD - Exports each span immediately (only for debugging)
// const simpleProcessor = new SimpleSpanProcessor(exporter);
```
##### 2. Skip Non-Critical Endpoints
Exclude health checks and metrics endpoints:
```typescript title="src/telemetry.ts" showLineNumbers
const sdk = new NodeSDK({
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-http": {
ignoreIncomingPaths: [
"/health",
"/metrics",
"/favicon.ico",
"/robots.txt",
],
},
}),
],
});
```
##### 3. Limit Attribute Sizes
Prevent large attributes from consuming memory:
```typescript title="src/utils/tracing.ts" showLineNumbers
export function addSafeAttribute(
span: Span,
key: string,
value: string | number | boolean,
maxLength: number = 256,
) {
if (typeof value === "string" && value.length > maxLength) {
value = value.substring(0, maxLength) + "... (truncated)";
}
span.setAttribute(key, value);
}
// Usage
addSafeAttribute(span, "article.content", article.content, 500);
addSafeAttribute(span, "http.request.body", JSON.stringify(req.body), 1024);
```
##### 4. Optimize Metric Export Intervals
Adjust metric export frequency for production:
```typescript title="src/telemetry.ts" showLineNumbers
const sdk = new NodeSDK({
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: "http://otel-collector:4318/v1/metrics",
}),
// ✅ Production: Export every 60 seconds (reduces network overhead)
exportIntervalMillis: 60000,
}),
});
```
### FAQ
#### Does Express.js OpenTelemetry instrumentation work with async/await?
Yes, OpenTelemetry fully supports Express.js async/await patterns. The NodeSDK
automatically preserves context across async operations, ensuring parent-child
span relationships are maintained correctly. All automatically instrumented
libraries (Mongoose, IORedis, etc.) work seamlessly with async/await.
#### What is the performance impact of Express.js instrumentation?
Typical overhead is 0.5-2ms added latency per request, 2-5% CPU increase, and
10-50MB additional memory usage. This impact is minimal and acceptable for most
production applications. Using BatchSpanProcessor and excluding high-volume
endpoints further reduces overhead.
#### Which Node.js and Express versions does OpenTelemetry support?
- **Node.js**: 24.0.0+ (Krypton LTS recommended, active until April 2028)
- **Express**: 5.0.0+ (5.0.1+ recommended for latest security improvements)
- **OpenTelemetry SDK**: 0.200+ (always use latest stable version for bug
fixes)
- Full TypeScript support with type definitions included
#### How do I instrument MongoDB and Mongoose queries in Express.js?
Install `@opentelemetry/instrumentation-mongoose` and register it in your
NodeSDK instrumentations array before connecting to MongoDB. All Mongoose
queries are automatically traced with operation name, collection, query
execution time, and database details. N+1 queries are visible in the span
hierarchy.
```typescript
import { MongooseInstrumentation } from "@opentelemetry/instrumentation-mongoose";
const sdk = new NodeSDK({
instrumentations: [new MongooseInstrumentation()],
});
sdk.start();
await mongoose.connect(MONGODB_URI); // Connect AFTER SDK start
```
**Note**: Mongoose 9.x is not yet supported. Use Mongoose 8.x (8.20.1+
recommended).
#### How does distributed tracing work across Express.js microservices?
OpenTelemetry uses W3C Trace Context headers (`traceparent`, `tracestate`) to
propagate trace context between services. Express HTTP instrumentation
automatically extracts these headers from incoming requests and injects them
into outgoing HTTP calls, enabling end-to-end distributed traces across your
entire microservices architecture.
#### What's the difference between traces and metrics in Express.js?
- **Traces**: Show individual request flows with detailed timing and call
hierarchy (e.g., "this specific API call took 150ms with 3 database queries")
- **Metrics**: Aggregate measurements over time for monitoring trends (e.g.,
"average response time is 120ms, 99th percentile is 500ms")
Use both together: traces for debugging specific issues, metrics for monitoring
overall health and setting up alerts.
#### How do I debug N+1 database query problems in Express.js?
View the span hierarchy in base14 Scout TraceX. N+1 queries appear as many
sequential database spans under a single parent span. For example, a list
endpoint that loads 10 articles with separate author queries will show 1
articles query span followed by 10 author query spans, making the N+1 pattern
obvious.
#### Can I use OpenTelemetry with TypeScript in Express.js?
Yes, OpenTelemetry has full first-class TypeScript support with comprehensive
type definitions. All examples in this guide use TypeScript syntax. The
instrumentation works identically with JavaScript - just omit type annotations.
#### How do I instrument background jobs with BullMQ?
Create manual spans in BullMQ workers to trace job execution. Trace context is
automatically propagated through Redis (via IORedis instrumentation). Ensure
IORedis instrumentation is enabled in your NodeSDK configuration.
#### Does instrumentation affect WebSocket connections (Socket.IO)?
Yes, Socket.IO connections can be traced by creating manual spans for connection
events. Each connection creates a span tracking the connection lifetime,
subscriptions, and events. See the Framework-Specific Features section for full
Socket.IO tracing examples.
#### How do I handle multi-tenancy in Express.js traces?
Add tenant identification to span attributes using the active span or request
middleware:
```typescript
const currentSpan = trace.getActiveSpan();
if (currentSpan) {
currentSpan.setAttributes({
"tenant.id": req.tenant.id,
"tenant.name": req.tenant.name,
});
}
```
Then filter and query by tenant attributes in base14 Scout dashboard.
### What's Next?
#### Advanced Topics
- [OpenTelemetry Collector Configuration](../../collector-setup/otel-collector-config.md)
\- Advanced collector features, processors, and exporters
#### base14 Scout Platform Features
- [Creating Alerts with LogX](../../../guides/creating-alerts-with-logx.md) -
Set up alerts based on traces and metrics
- [Dashboard Creation](../../../guides/create-your-first-dashboard.md) - Build
custom dashboards for Express.js applications
#### Deployment and Operations
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) -
Local development environment with collector
- [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md) -
Production Kubernetes deployment
- [Scout Exporter Configuration](../../collector-setup/scout-exporter.md) -
Configure authentication and endpoints
### Complete Example
A complete production-ready example with Express 5.x, TypeScript, MongoDB
(Mongoose), Redis, BullMQ, Socket.IO, and comprehensive OpenTelemetry
instrumentation is available at:
**GitHub**:
[base-14/examples/nodejs/express-typescript-mongodb](https://github.com/base-14/examples/tree/main/nodejs/express-typescript-mongodb)
**Features**:
- Full auto-instrumentation with NodeSDK
- Custom spans, metrics, and events
- Background job tracing with BullMQ
- WebSocket tracing with Socket.IO
- Production Docker deployment
- 74.31% test coverage with Vitest
- Security: Helmet, CORS, rate limiting, JWT, XSS protection
- Graceful shutdown handling
#### Complete package.json
```json title="package.json" showLineNumbers
{
"name": "express-typescript-mongodb-otel",
"version": "1.0.0",
"type": "module",
"description": "Express.js + TypeScript + MongoDB + OpenTelemetry example",
"scripts": {
"build": "tsc",
"start": "node --import ./dist/instrumentation.js dist/index.js",
"dev": "ts-node src/index.ts",
"test": "vitest run",
"test:coverage": "vitest run --coverage"
},
"engines": {
"node": ">=24.0.0"
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.208.0",
"@opentelemetry/sdk-node": "^0.208.0",
"@opentelemetry/sdk-logs": "^0.208.0",
"@opentelemetry/auto-instrumentations-node": "^0.67.2",
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.208.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
"@opentelemetry/instrumentation-express": "^0.57.0",
"@opentelemetry/instrumentation-http": "^0.208.0",
"@opentelemetry/instrumentation-mongodb": "^0.61.0",
"@opentelemetry/instrumentation-mongoose": "^0.55.0",
"@opentelemetry/instrumentation-ioredis": "^0.56.0",
"@opentelemetry/instrumentation-winston": "^0.53.0",
"@opentelemetry/resources": "^2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0",
"express": "^5.0.1",
"mongoose": "^8.20.1",
"ioredis": "^5.4.2"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^24.10.1",
"typescript": "^5.7.2",
"vitest": "^4.0.15",
"@vitest/coverage-v8": "^4.0.15"
}
}
```
> For additional dependencies like BullMQ, Socket.IO, security libraries, and
> more, see the
> [complete example](https://github.com/base-14/examples/tree/main/nodejs/express-typescript-mongodb).
#### Additional Configuration Files
For complete production-ready configuration files, see the example repository:
- [src/telemetry.ts][example-telemetry] - Complete telemetry setup with logs,
metrics, and traces
- [.env.example][example-env] - All environment variables with Scout
configuration
- [Dockerfile][example-dockerfile] - Multi-stage production build
- [compose.yml][example-compose] - Full stack with health checks and networks
[example-telemetry]: https://github.com/base-14/examples/tree/main/nodejs/express-typescript-mongodb/src/telemetry.ts
[example-env]: https://github.com/base-14/examples/tree/main/nodejs/express-typescript-mongodb/.env.example
[example-dockerfile]: https://github.com/base-14/examples/tree/main/nodejs/express-typescript-mongodb/Dockerfile
[example-compose]: https://github.com/base-14/examples/tree/main/nodejs/express-typescript-mongodb/compose.yml
With instrumentation configured, you can
[track Express.js endpoints in Scout APM](https://base14.io/scout/apm)
— monitor route latency, middleware performance, and downstream service
calls in real time.
### References
- [Official OpenTelemetry Node.js Documentation](https://opentelemetry.io/docs/languages/js/)
- [Express.js Documentation](https://expressjs.com/)
- [OpenTelemetry Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/)
- [Official Metrics Documentation](https://opentelemetry.io/docs/concepts/signals/metrics/)
- [Official Traces Documentation](https://opentelemetry.io/docs/concepts/signals/traces/)
### Related Guides
- [Angular Instrumentation](./angular.md) - browser front-end that propagates
trace context into an Express API
- [NestJS Instrumentation](./nestjs.md) - Structured framework that runs on top
of Express
- [Next.js Instrumentation](./nextjs-scout.md) - Full-stack React framework on Node.js
- [Fastify Instrumentation](./fastify.md) - High-performance Node.js web
framework
- [Hono Instrumentation](./hono.md) - Lightweight, edge-first Node.js framework
---
## FastAPI OpenTelemetry Instrumentation - Traces, Metrics & Logs Setup
Implement OpenTelemetry instrumentation for FastAPI applications to enable
comprehensive application performance monitoring (APM), distributed tracing, and
observability. This guide shows you how to auto-instrument your FastAPI
application to collect traces and metrics from HTTP requests, database queries,
and external API calls using the OpenTelemetry Python SDK with minimal code
changes.
FastAPI is a modern, async-first Python framework in the same ASGI space as
[Litestar](./litestar.md). [Flask](./flask.md) and [Django](./django.md) are the
established synchronous options.
FastAPI applications benefit from automatic instrumentation of the framework
itself, as well as popular libraries including SQLAlchemy, Redis, PostgreSQL,
and dozens of commonly used Python components. With OpenTelemetry, you can
monitor production performance, debug slow requests, trace distributed
transactions across microservices, and identify database query bottlenecks
without significant code modifications. The async-native design of FastAPI works
seamlessly with OpenTelemetry's context propagation.
Whether you're implementing observability for the first time, migrating from
commercial APM solutions like DataDog or New Relic, or troubleshooting
performance issues in production, this guide provides production-ready
configurations and best practices for FastAPI OpenTelemetry instrumentation.
You'll learn how to set up auto-instrumentation, configure custom spans for
business logic, optimize performance, and deploy with Docker.
:::tip TL;DR
Install `opentelemetry-instrumentation-fastapi` and call
`FastAPIInstrumentor().instrument_app(app)` at startup. SQLAlchemy, Redis, and
HTTP client libraries are instrumented automatically via the bootstrap command.
Use `BatchSpanProcessor` with the OTLP exporter to send traces and metrics to
base14 Scout with no changes to your route handlers.
:::
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Overview
This guide demonstrates how to:
- Set up OpenTelemetry instrumentation for FastAPI applications
- Configure automatic request and response tracing for HTTP endpoints
- Instrument database operations with SQLAlchemy auto-instrumentation
- Implement custom spans for business logic and external API calls
- Collect and export HTTP metrics using custom middleware
- Configure production-ready telemetry with BatchSpanProcessor
- Export telemetry data to base14 Scout via OTLP
- Deploy instrumented applications with Docker and Docker Compose
- Troubleshoot common instrumentation issues
- Optimize performance impact in production environments
### Who This Guide Is For
This documentation is designed for:
- **FastAPI developers**: implementing observability and distributed tracing for
the first time in async Python applications
- **DevOps engineers**: deploying FastAPI applications with production
monitoring requirements and container orchestration
- **Engineering teams**: migrating from DataDog, New Relic, or other commercial
APM solutions to open-source observability
- **Backend developers**: debugging performance issues, slow database queries,
or async operation bottlenecks in FastAPI services
- **Platform teams**: standardizing observability across multiple FastAPI
microservices with consistent instrumentation patterns
### Prerequisites
Before starting, ensure you have:
- **Python 3.9 or later** installed (Python 3.13+ recommended for best
performance)
- **FastAPI 0.100.0 or later** installed in your project (0.115.6+ recommended)
- **Scout Collector** configured and accessible from your application
- See [Docker Compose Setup](../../collector-setup/docker-compose-example.md)
for local development
- See [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md)
for production deployment
- **Basic understanding** of OpenTelemetry concepts (traces, spans, attributes)
- Access to package installation via `pip` or your preferred package manager
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version | Notes |
| --------------------------------- | --------------- | ------------------- | ------------------------------------------------------ |
| **Python** | 3.9 | 3.13+ | Python 3.13+ offers best performance and type system |
| **FastAPI** | 0.100.0 | 0.115.6+ | Full Pydantic v2 and modern dependency injection |
| **OpenTelemetry SDK** | 1.20.0 | 1.29+ | Core SDK for traces and metrics |
| **OpenTelemetry Instrumentation** | 0.41b0 | 0.50b0+ | FastAPI auto-instrumentation |
| **SQLAlchemy** (optional) | 1.4+ | 2.0.36+ | For database instrumentation |
| **Pydantic** | 2.0+ | 2.10+ | Included with FastAPI, v2 required for modern patterns |
#### Supported Libraries
OpenTelemetry automatically instruments these commonly used libraries:
- **Web frameworks**: FastAPI, Starlette
- **Databases**: SQLAlchemy, asyncpg, psycopg2, pymongo
- **HTTP clients**: requests, httpx, aiohttp
- **Task queues**: Celery (with additional instrumentation)
- **Caching**: Redis, memcached
### Installation
#### Core Packages
Install the required OpenTelemetry packages for FastAPI instrumentation:
```bash
pip install opentelemetry-api
pip install opentelemetry-sdk
pip install opentelemetry-instrumentation-fastapi
pip install opentelemetry-exporter-otlp
```
#### Optional Instrumentation Libraries
Add these packages to instrument additional components:
```bash
# HTTP client instrumentation
pip install opentelemetry-instrumentation-requests
pip install opentelemetry-instrumentation-httpx
# Database instrumentation
pip install opentelemetry-instrumentation-sqlalchemy
# Redis instrumentation
pip install opentelemetry-instrumentation-redis
```
#### Complete Requirements File
For production applications, add all dependencies to `requirements.txt`:
```plaintext title="requirements.txt" showLineNumbers
# Web framework
fastapi[all]
uvicorn[standard]
# OpenTelemetry core
opentelemetry-api
opentelemetry-sdk
opentelemetry-exporter-otlp
# OpenTelemetry instrumentation
opentelemetry-instrumentation-fastapi
opentelemetry-instrumentation-requests
opentelemetry-instrumentation-sqlalchemy
# Optional: Application dependencies
sqlalchemy
psycopg2-binary
pydantic-settings
```
Then install all dependencies:
```bash
pip install -r requirements.txt
```
### Configuration
FastAPI OpenTelemetry instrumentation can be configured in multiple ways
depending on your application architecture and deployment requirements. This
section covers different setup approaches and advanced configuration options.
#### Setup Approaches
Choose the initialization method that best fits your application architecture:
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
##### Inline Configuration (Quick Start)
The simplest approach is to configure OpenTelemetry directly in your main
application file. This works well for small applications and development
environments.
```python title="main.py" showLineNumbers
from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
# Configure trace provider with service name
resource = Resource.create({"service.name": "my-fastapi-service"})
trace.set_tracer_provider(TracerProvider(resource=resource))
# Set up trace exporter
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces")
)
)
# Create FastAPI app
app = FastAPI()
# Instrument the FastAPI app
FastAPIInstrumentor.instrument_app(app)
@app.get("/")
def root():
return {"message": "Hello World"}
```
This configuration automatically captures:
- HTTP request method, path, and status code
- Request duration and timing
- Error and exception information
- Request headers (configurable)
- Query parameters and path parameters
```mdx-code-block
```
##### Separate Telemetry Module (Recommended)
For better code organization and reusability, create a dedicated telemetry
module. This approach is recommended for production applications.
```python title="app/telemetry.py" showLineNumbers
import os
from opentelemetry import trace, metrics
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
def setup_telemetry(otel_endpoint: str) -> None:
"""
Initialize OpenTelemetry tracing and metrics.
Args:
otel_endpoint: OTLP collector endpoint (e.g., "localhost:4318")
"""
# Get service name from environment or use default
service_name = os.getenv("OTEL_SERVICE_NAME", "fastapi-app")
# Create resource with service identification
resource = Resource.create({
"service.name": service_name,
"service.version": os.getenv("APP_VERSION", "1.0.0"),
"deployment.environment": os.getenv("ENVIRONMENT", "development"),
"environment": os.getenv("ENVIRONMENT", "development")
})
# Configure trace provider
provider = TracerProvider(resource=resource)
provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint=f"http://{otel_endpoint}/v1/traces")
)
)
trace.set_tracer_provider(provider)
# Configure metrics provider
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=f"http://{otel_endpoint}/v1/metrics"),
export_interval_millis=5000 # Export every 5 seconds
)
metrics.set_meter_provider(
MeterProvider(resource=resource, metric_readers=[metric_reader])
)
```
Then use it in your main application file:
```python title="app/main.py" showLineNumbers
from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
import os
from .telemetry import setup_telemetry
# Initialize telemetry before creating the app
otel_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4318")
setup_telemetry(otel_endpoint)
# Create FastAPI app
app = FastAPI()
# Instrument FastAPI and HTTP clients
FastAPIInstrumentor.instrument_app(app)
RequestsInstrumentor().instrument()
@app.get("/")
def root():
return {"message": "Hello World"}
```
```mdx-code-block
```
##### CLI Auto-Instrumentation (Zero-Code)
The simplest approach for containerized deployments uses the OpenTelemetry CLI
tool for zero-code instrumentation. This is the **recommended starting point**
for new projects.
**Installation:**
```bash
# Install the distro package (includes CLI tools)
pip install opentelemetry-distro
pip install opentelemetry-exporter-otlp
# Bootstrap auto-instrumentation (installs all available instrumentations)
opentelemetry-bootstrap -a install
```
**Development mode** (console output):
```bash
opentelemetry-instrument \
--traces_exporter console \
--metrics_exporter console \
--service_name fastapi-app \
uvicorn app.main:app --reload
```
**Production mode** (OTLP export):
```bash
# Set environment variables
export OTEL_SERVICE_NAME="fastapi-app"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
export OTEL_TRACES_EXPORTER="otlp"
export OTEL_METRICS_EXPORTER="otlp"
# Run with auto-instrumentation (no code changes needed!)
opentelemetry-instrument uvicorn app.main:app --host 0.0.0.0 --port 8000
```
**Docker deployment:**
```dockerfile title="Dockerfile" showLineNumbers
FROM python:3.12-slim
WORKDIR /app
# Install dependencies including OpenTelemetry
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && \
pip install opentelemetry-distro opentelemetry-exporter-otlp && \
opentelemetry-bootstrap -a install
# Copy application
COPY ./app ./app
# Run with auto-instrumentation
CMD ["opentelemetry-instrument", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
**Advantages:**
- ✅ Zero code changes required
- ✅ Automatic instrumentation of FastAPI, database clients, HTTP clients
- ✅ Easy to enable/disable via environment variables
- ✅ Perfect for containerized deployments
**When to use:** Production deployments where you want automatic instrumentation
without modifying application code.
```mdx-code-block
```
#### Advanced Configuration
Fine-tune instrumentation behavior for specific requirements:
```mdx-code-block
```
##### Selective Instrumentation
To instrument only specific components or exclude certain endpoints:
```python title="main.py" showLineNumbers
from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry import trace
# ... telemetry setup ...
app = FastAPI()
# Instrument with custom configuration
FastAPIInstrumentor.instrument_app(
app,
excluded_urls="/health,/metrics,/docs,/openapi.json", # Skip these endpoints
tracer_provider=trace.get_tracer_provider(),
)
@app.get("/health")
def health_check():
"""This endpoint won't be traced"""
return {"status": "healthy"}
@app.get("/api/users")
def get_users():
"""This endpoint will be traced"""
return {"users": []}
```
```mdx-code-block
```
#### Approach 5: Request/Response Hooks
Add custom attributes to spans using hooks for advanced use cases:
```python title="app/main.py" showLineNumbers
from typing import Any
from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.trace import Span
app = FastAPI()
def server_request_hook(span: Span, scope: dict[str, Any]) -> None:
"""
Hook called when a request is received.
Add custom attributes based on request data.
"""
if span and span.is_recording():
# Add custom business context
span.set_attribute("app.user_tier", scope.get("user_tier", "free"))
span.set_attribute("app.request_id", scope.get("request_id"))
# Add query parameters as attributes
query_string = scope.get("query_string", b"").decode()
if query_string:
span.set_attribute("http.query_string", query_string)
def client_request_hook(span: Span, scope: dict[str, Any]) -> None:
"""Hook for outbound HTTP requests."""
if span and span.is_recording():
span.set_attribute("app.calling_service", "fastapi-app")
def client_response_hook(span: Span, message: dict[str, Any]) -> None:
"""Hook called when a response is received."""
if span and span.is_recording():
# Track response metadata
content_type = message.get("headers", {}).get("content-type")
if content_type:
span.set_attribute("http.response.content_type", content_type)
# Instrument with hooks
FastAPIInstrumentor.instrument_app(
app,
server_request_hook=server_request_hook,
client_request_hook=client_request_hook,
client_response_hook=client_response_hook,
)
```
```mdx-code-block
```
#### Approach 6: HTTP Header Capture
Capture and sanitize HTTP headers automatically:
```python title="app/main.py" showLineNumbers
from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
app = FastAPI()
# Configure header capture with sanitization
FastAPIInstrumentor.instrument_app(
app,
# Capture specific request headers (lowercase with underscores)
http_capture_headers_server_request=[
"content-type",
"user-agent",
"accept",
"x-request-id",
],
# Capture specific response headers
http_capture_headers_server_response=[
"content-type",
"content-length",
"x-correlation-id",
],
# IMPORTANT: Sanitize sensitive headers (never capture these)
# This prevents accidental leaking of secrets
# Note: Even if listed above, these will be redacted
excluded_urls="/health,/metrics",
)
# Headers appear as span attributes:
# http.request.header.content_type
# http.request.header.user_agent
# http.response.header.content_type
```
**Security Note:** Never capture authorization headers, cookies, API keys, or
any authentication tokens. Use sanitization to protect sensitive data.
```mdx-code-block
```
### Traces
Traces provide the complete picture of what happens when a request flows through
your FastAPI application. They capture the entire lifecycle from the incoming
HTTP request, through your business logic, database queries, external API calls,
and finally the response sent back to the client.
#### Automatic Trace Collection
Once instrumented, FastAPI automatically captures detailed trace information for
every request:
**Captured Information:**
- HTTP method, path, and status code
- Request duration and timing breakdown
- Request and response headers (configurable)
- Query parameters and path parameters
- Error and exception stack traces
- Distributed trace context propagation (W3C Trace Context)
**Trace Hierarchy:**
```text
HTTP Request Span (root)
├── Route Handler Span
│ ├── Database Query Span
│ ├── External API Call Span
│ └── Business Logic Span
└── Response Span
```
#### Key Tracing Features
- **Automatic HTTP tracking**: Every endpoint is automatically traced with no
code changes
- **Error capturing**: Exceptions are automatically recorded with full stack
traces
- **Context propagation**: Distributed traces work across microservices using
W3C Trace Context headers
- **Custom attributes**: Add business-specific metadata to spans (covered in
Custom Instrumentation section)
- **Async support**: Full support for FastAPI's async/await patterns
> View traces in your base14 Scout dashboard to analyze request flows and
> identify bottlenecks.
##### Reference
[Official Traces Documentation](https://opentelemetry.io/docs/concepts/signals/traces/)
### Metrics
OpenTelemetry metrics capture runtime measurements of your FastAPI application
including HTTP request counts, latencies, response status codes, and custom
business metrics. Unlike traces that show individual request flows, metrics
aggregate data over time for monitoring trends and alerting.
#### Custom Metrics Middleware
Create a custom middleware to capture HTTP metrics for all requests:
```python title="app/metrics_middleware.py" showLineNumbers
import os
import time
from starlette.middleware.base import BaseHTTPMiddleware
from opentelemetry.metrics import get_meter
class MetricsMiddleware(BaseHTTPMiddleware):
def __init__(self, app):
super().__init__(app)
service_name = os.getenv("OTEL_SERVICE_NAME", "fastapi-app")
self.meter = get_meter(service_name)
# Create metrics instruments
self.http_requests_counter = self.meter.create_counter(
name="http.server.requests",
unit="1",
description="Total number of HTTP requests"
)
self.http_request_duration = self.meter.create_histogram(
name="http.server.duration",
unit="ms",
description="HTTP request duration in milliseconds"
)
async def dispatch(self, request, call_next):
start_time = time.time()
# Process request
response = await call_next(request)
# Calculate duration
duration_ms = (time.time() - start_time) * 1000
# Record metrics with attributes
attributes = {
"http.method": request.method,
"http.route": request.url.path,
"http.status_code": response.status_code,
}
self.http_requests_counter.add(1, attributes)
self.http_request_duration.record(duration_ms, attributes)
return response
```
Add the middleware to your FastAPI application:
```python title="app/main.py" showLineNumbers
from fastapi import FastAPI
from .metrics_middleware import MetricsMiddleware
from .telemetry import setup_telemetry
# Initialize telemetry
setup_telemetry("localhost:4318")
app = FastAPI()
# Add metrics middleware BEFORE FastAPI instrumentation
app.add_middleware(MetricsMiddleware)
# Then add FastAPI instrumentation
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
FastAPIInstrumentor.instrument_app(app)
```
#### Available Metrics
Once configured, these metrics are automatically collected:
| Metric Name | Type | Description | Attributes |
| ----------------------------- | ------------- | --------------------------- | -------------------------- |
| `http.server.requests` | Counter | Total HTTP requests | method, route, status_code |
| `http.server.duration` | Histogram | Request duration in ms | method, route, status_code |
| `http.server.active_requests` | UpDownCounter | Currently active requests | method, route |
| `http.server.response.size` | Histogram | Response body size in bytes | method, route, status_code |
> View these metrics in base14 Scout to create dashboards, set up alerts, and
> monitor application health.
### Production Configuration
Production environments require careful configuration of OpenTelemetry to
balance observability needs with performance and reliability. This section
covers production-ready patterns.
#### BatchSpanProcessor Configuration
Configure BatchSpanProcessor parameters for optimal performance:
```python title="app/telemetry.py" showLineNumbers
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
# Production-optimized batch processor
batch_processor = BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces"),
max_queue_size=2048, # Maximum spans in queue
schedule_delay_millis=5000, # Export every 5 seconds
max_export_batch_size=512, # Maximum spans per export
export_timeout_millis=30000 # Timeout for export operation
)
trace.get_tracer_provider().add_span_processor(batch_processor)
```
#### Resource Attributes for Production
Add comprehensive resource attributes to identify your service:
```python title="app/telemetry.py" showLineNumbers
import os
import socket
from opentelemetry.sdk.resources import Resource
resource = Resource.create({
# Service identification
"service.name": os.getenv("OTEL_SERVICE_NAME", "fastapi-app"),
"service.version": os.getenv("APP_VERSION", "1.0.0"),
"service.namespace": os.getenv("SERVICE_NAMESPACE", "production"),
# Deployment information
"deployment.environment": os.getenv("ENVIRONMENT", "development"),
"environment": os.getenv("ENVIRONMENT", "development"),
"deployment.region": os.getenv("AWS_REGION", "us-east-1"),
# Instance identification
"service.instance.id": socket.gethostname(),
"host.name": socket.gethostname(),
"host.type": os.getenv("HOST_TYPE", "container"),
# Container information (if applicable)
"container.id": os.getenv("HOSTNAME", ""),
"container.name": os.getenv("CONTAINER_NAME", ""),
# Kubernetes information (if applicable)
"k8s.namespace.name": os.getenv("K8S_NAMESPACE", ""),
"k8s.pod.name": os.getenv("K8S_POD_NAME", ""),
"k8s.deployment.name": os.getenv("K8S_DEPLOYMENT_NAME", ""),
})
```
#### Environment-Based Configuration
Use environment variables to configure telemetry without code changes:
```python title="app/telemetry.py" showLineNumbers
import os
import logging
from opentelemetry import trace, metrics
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
logger = logging.getLogger(__name__)
def setup_telemetry() -> None:
"""Initialize telemetry with environment-based configuration."""
# Get configuration from environment
otel_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
environment = os.getenv("ENVIRONMENT", "development")
service_name = os.getenv("OTEL_SERVICE_NAME", "fastapi-app")
# Create resource
resource = Resource.create({
"service.name": service_name,
"deployment.environment": environment,
"environment": environment,
"service.version": os.getenv("APP_VERSION", "dev"),
})
# Configure trace provider
provider = TracerProvider(resource=resource)
# Add exporters based on environment
if environment == "development":
# Console exporter for development
provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
logger.info("Using console exporter for traces")
else:
# OTLP exporter for production/staging
provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint=f"{otel_endpoint}/v1/traces"),
max_queue_size=2048,
schedule_delay_millis=5000,
)
)
logger.info(f"Using OTLP exporter at {otel_endpoint}")
trace.set_tracer_provider(provider)
# Configure metrics
if environment != "development":
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=f"{otel_endpoint}/v1/metrics"),
export_interval_millis=5000
)
metrics.set_meter_provider(
MeterProvider(resource=resource, metric_readers=[metric_reader])
)
```
#### Docker Compose Configuration
Example `docker-compose.yml` for production-like deployment:
```yaml title="docker-compose.yml" showLineNumbers
services:
app:
build: .
ports:
- "8000:8000"
environment:
# Application config
ENVIRONMENT: development
APP_VERSION: "1.2.0"
# OpenTelemetry config
OTEL_SERVICE_NAME: fastapi-app
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
# Database config
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: myapp
depends_on:
- postgres
- otel-collector
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
postgres:
image: postgres:18
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: myapp
volumes:
- postgres_data:/var/lib/postgresql/data
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- "4318:4318" # OTLP HTTP receiver
- "55679:55679" # zpages for debugging
volumes:
postgres_data:
```
#### Environment Variables Template
Create a `.env.example` file for your team:
```bash title=".env.example" showLineNumbers
# Application
ENVIRONMENT=development
APP_VERSION=1.0.0
SERVICE_NAMESPACE=my-company
# OpenTelemetry
OTEL_SERVICE_NAME=fastapi-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
# Database
DB_HOST=postgres
DB_PORT=5432
DB_NAME=myapp
DB_USER=postgres
DB_PASSWORD=changeme
# Security (for production, use secrets management)
SECRET_KEY=your-secret-key-here
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=60
```
#### Dockerfile with OpenTelemetry
Build a production-ready Docker image:
```dockerfile title="Dockerfile" showLineNumbers
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY ./app ./app
# Create non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health')"
# Run application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
### Framework-Specific Features
FastAPI's integration with OpenTelemetry automatically instruments several
framework components and commonly used libraries. This section covers automatic
instrumentation for databases, HTTP clients, and other integrations.
#### SQLAlchemy Database Instrumentation
OpenTelemetry automatically instruments SQLAlchemy database queries, providing
detailed visibility into database operations.
**Installation:**
```bash
pip install opentelemetry-instrumentation-sqlalchemy
```
**Automatic Instrumentation:**
```python title="app/database.py" showLineNumbers
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
# Create database engine
DATABASE_URL = "postgresql://user:password@localhost:5432/mydb"
engine = create_engine(DATABASE_URL)
# Instrument SQLAlchemy BEFORE creating sessions
SQLAlchemyInstrumentor().instrument(
engine=engine,
service="fastapi-app",
enable_commenter=True, # Add SQL comments with trace context
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
```
This automatically captures:
- SQL query text with parameters
- Query execution time
- Database connection details
- Transaction boundaries
- N+1 query detection (via span hierarchy)
**Example Traced Query:**
```python title="app/routers/users.py" showLineNumbers
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ..database import SessionLocal
from .. import models
router = APIRouter()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.get("/users/{user_id}")
def get_user(user_id: int, db: Session = Depends(get_db)):
# This query is automatically traced with full SQL details
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
```
#### HTTP Client Instrumentation
Trace outbound HTTP requests to external APIs and services.
**For `requests` library:**
```bash
pip install opentelemetry-instrumentation-requests
```
```python title="app/main.py" showLineNumbers
from opentelemetry.instrumentation.requests import RequestsInstrumentor
# Instrument requests library globally
RequestsInstrumentor().instrument()
# Now all requests calls are automatically traced
import requests
@app.get("/external-api")
async def call_external_api():
# This HTTP call is automatically traced
response = requests.get("https://api.example.com/data")
return response.json()
```
**For `httpx` library (async HTTP):**
```bash
pip install opentelemetry-instrumentation-httpx
```
```python title="app/main.py" showLineNumbers
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
import httpx
# Instrument httpx globally
HTTPXClientInstrumentor().instrument()
@app.get("/async-external-api")
async def call_async_external_api():
async with httpx.AsyncClient() as client:
# This async HTTP call is automatically traced
response = await client.get("https://api.example.com/data")
return response.json()
```
#### Dependency Injection with Tracing
FastAPI's dependency injection system works seamlessly with OpenTelemetry:
```python title="app/dependencies.py" showLineNumbers
from typing import Annotated
from fastapi import Depends, Header, HTTPException
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
def get_current_user(token: Annotated[str, Header()]) -> dict[str, str]:
"""Dependency that validates user token - automatically traced"""
with tracer.start_as_current_span("validate_user_token"):
# Token validation logic
if not validate_token(token):
raise HTTPException(status_code=401, detail="Invalid token")
return get_user_from_token(token)
@app.get("/protected")
def protected_endpoint(
user: Annotated[dict[str, str], Depends(get_current_user)]
) -> dict[str, dict[str, str]]:
"""The dependency span appears as a child of the HTTP request span"""
return {"user": user}
```
#### Background Tasks with Tracing
Trace FastAPI background tasks:
```python title="app/main.py" showLineNumbers
from fastapi import BackgroundTasks
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
def send_email(email: str, message: str) -> None:
"""Background task - create manual span"""
with tracer.start_as_current_span("send_email") as span:
span.set_attribute("email.to", email)
span.set_attribute("email.message_length", len(message))
# Email sending logic
print(f"Sending email to {email}")
@app.post("/register")
async def register_user(
email: str,
background_tasks: BackgroundTasks
) -> dict[str, str]:
# Add background task
background_tasks.add_task(send_email, email, "Welcome!")
return {"message": "User registered"}
```
### Custom Instrumentation
While auto-instrumentation captures HTTP requests and database queries, custom
instrumentation lets you trace business logic, add contextual attributes, and
instrument specific operations.
#### Creating Custom Spans
Add manual spans to trace specific operations:
```python title="app/services/order_service.py" showLineNumbers
from typing import Annotated
from fastapi import APIRouter, HTTPException, Body
from pydantic import BaseModel
from opentelemetry import trace
router = APIRouter()
tracer = trace.get_tracer(__name__)
class OrderCreate(BaseModel):
product_id: int
quantity: int
payment_method: str
order_type: str
amount: float
class OrderResponse(BaseModel):
order_id: int
status: str
@router.post("/orders")
async def create_order(
order: Annotated[OrderCreate, Body()]
) -> OrderResponse:
# Parent span is automatically the HTTP request span
with tracer.start_as_current_span("create_order") as span:
# Add custom attributes
span.set_attribute("order.type", order.order_type)
span.set_attribute("order.amount", order.amount)
span.set_attribute("order.quantity", order.quantity)
# Nested span for inventory check
with tracer.start_as_current_span("check_inventory") as inventory_span:
inventory_span.set_attribute("product.id", order.product_id)
available = await check_product_availability(order.product_id)
inventory_span.set_attribute("inventory.available", available)
if not available:
span.set_status(trace.Status(trace.StatusCode.ERROR, "Out of stock"))
raise HTTPException(status_code=400, detail="Product out of stock")
# Nested span for payment processing
with tracer.start_as_current_span("process_payment") as payment_span:
payment_span.set_attribute("payment.method", order.payment_method)
payment_result = await process_payment(order)
payment_span.set_attribute("payment.transaction_id", payment_result["transaction_id"])
span.add_event("Order created successfully")
return OrderResponse(order_id=123, status="created")
```
#### Adding Custom Attributes
Enrich spans with business-specific metadata:
```python title="app/routers/posts.py" showLineNumbers
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from opentelemetry import trace
router = APIRouter()
class Post(BaseModel):
id: int
title: str
author_id: int
category: str
published: bool
@router.get("/posts/{post_id}")
async def get_post(post_id: int) -> Post:
# Get current span (automatically created by FastAPI instrumentation)
current_span = trace.get_current_span()
# Add custom attributes to existing span
current_span.set_attribute("post.id", post_id)
current_span.set_attribute("user.action", "view_post")
# Fetch post
post = await fetch_post_from_db(post_id)
if not post:
current_span.set_attribute("post.found", False)
raise HTTPException(status_code=404, detail="Post not found")
current_span.set_attribute("post.found", True)
current_span.set_attribute("post.author_id", post.author_id)
current_span.set_attribute("post.category", post.category)
current_span.set_attribute("post.published", post.published)
return post
```
#### Error Handling and Status
Record errors and exceptions in spans:
```python title="app/services/external_api.py" showLineNumbers
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
import requests
tracer = trace.get_tracer(__name__)
def call_external_service(url: str):
with tracer.start_as_current_span("external_api_call") as span:
span.set_attribute("http.url", url)
try:
response = requests.get(url, timeout=5)
response.raise_for_status()
span.set_attribute("http.status_code", response.status_code)
span.set_status(Status(StatusCode.OK))
return response.json()
except requests.exceptions.Timeout as e:
# Record exception details
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, "Request timeout"))
raise
except requests.exceptions.HTTPError as e:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, f"HTTP {response.status_code}"))
raise
except Exception as e:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, "Unknown error"))
raise
```
#### Span Events
Add timestamped events to spans for debugging:
```python title="app/services/data_processor.py" showLineNumbers
from typing import Any
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
async def process_large_dataset(data: list[dict[str, Any]]) -> dict[str, int]:
"""Process large dataset in chunks with tracing."""
with tracer.start_as_current_span("process_dataset") as span:
span.set_attribute("dataset.size", len(data))
span.add_event("Processing started")
# Process in chunks
chunk_size = 100
processed_count = 0
for i in range(0, len(data), chunk_size):
chunk = data[i:i + chunk_size]
await process_chunk(chunk)
processed_count += len(chunk)
# Add event for each chunk
span.add_event(
"Chunk processed",
attributes={
"chunk.index": i // chunk_size,
"chunk.size": len(chunk),
"total.processed": processed_count
}
)
span.add_event("Processing completed")
span.set_attribute("dataset.processed", processed_count)
return {"processed": processed_count}
```
#### Semantic Conventions
Use OpenTelemetry semantic conventions for consistent attribute naming:
```python title="app/services/user_service.py" showLineNumbers
from opentelemetry import trace
from opentelemetry.semconv.trace import SpanAttributes
tracer = trace.get_tracer(__name__)
@app.post("/login")
async def login(username: str, password: str):
with tracer.start_as_current_span("user.login") as span:
# Use semantic conventions for HTTP attributes
span.set_attribute(SpanAttributes.HTTP_METHOD, "POST")
span.set_attribute(SpanAttributes.HTTP_ROUTE, "/login")
# Use semantic conventions for user attributes
span.set_attribute(SpanAttributes.ENDUSER_ID, username)
# Authentication logic
if authenticate(username, password):
span.set_attribute("auth.success", True)
return {"token": generate_token(username)}
else:
span.set_attribute("auth.success", False)
span.set_status(Status(StatusCode.ERROR, "Authentication failed"))
raise HTTPException(status_code=401, detail="Invalid credentials")
```
### Running Your Application
#### Development Mode
Run with console output for local development:
```python title="app/telemetry.py" showLineNumbers
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, BatchSpanProcessor
# Development configuration - print spans to console
if os.getenv("ENVIRONMENT") == "development":
console_processor = BatchSpanProcessor(ConsoleSpanExporter())
trace.get_tracer_provider().add_span_processor(console_processor)
```
Start the application:
```bash
# Set environment to development
export ENVIRONMENT=development
export OTEL_SERVICE_NAME=fastapi-app
# Run with uvicorn
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
#### Production Mode
Run with OTLP exporter pointing to Scout Collector:
```bash
# Set production environment variables
export ENVIRONMENT=development
export OTEL_SERVICE_NAME=fastapi-app
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
export APP_VERSION=1.0.0
# Run with production settings
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
```
#### Docker Deployment
Build and run with Docker:
```bash
# Build image
docker build -t fastapi-app:latest .
# Run container
docker run -d \
--name fastapi-app \
-p 8000:8000 \
-e OTEL_SERVICE_NAME=fastapi-app \
-e OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
-e ENVIRONMENT=development \
fastapi-app:latest
```
Or use Docker Compose (see Production Configuration section above).
### Troubleshooting
#### Verifying Instrumentation
Create a test endpoint to verify OpenTelemetry is working:
```python title="app/main.py" showLineNumbers
from opentelemetry import trace
@app.get("/health")
def health_check():
"""Health check endpoint that verifies tracing"""
current_span = trace.get_current_span()
if current_span.is_recording():
return {
"status": "healthy",
"tracing": "enabled",
"trace_id": format(current_span.get_span_context().trace_id, '032x'),
"span_id": format(current_span.get_span_context().span_id, '016x')
}
else:
return {
"status": "healthy",
"tracing": "disabled"
}
```
#### Common Issues
##### Issue: No traces appearing in Scout
**Solutions:**
1. Verify OTLP endpoint is accessible:
```bash
curl http://otel-collector:4318/v1/traces
```
2. Check telemetry initialization happens before FastAPI app creation:
```python
# Correct order:
setup_telemetry() # First
app = FastAPI() # Second
FastAPIInstrumentor.instrument_app(app) # Third
```
3. Enable console exporter to verify spans are being created:
```python
from opentelemetry.sdk.trace.export import ConsoleSpanExporter
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(ConsoleSpanExporter())
)
```
##### Issue: ImportError for OpenTelemetry packages
**Solutions:**
1. Verify all packages are installed:
```bash
pip list | grep opentelemetry
```
2. Reinstall with specific versions:
```bash
pip install --upgrade opentelemetry-api opentelemetry-sdk
pip install --upgrade opentelemetry-instrumentation-fastapi
```
3. Check for conflicting packages:
```bash
pip check
```
##### Issue: Database queries not traced
**Solutions:**
1. Ensure SQLAlchemy instrumentation is called BEFORE creating the engine:
```python
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
engine = create_engine(DATABASE_URL)
SQLAlchemyInstrumentor().instrument(engine=engine)
```
2. Verify the instrumentation package is installed:
```bash
pip install opentelemetry-instrumentation-sqlalchemy
```
##### Issue: High memory usage or performance degradation
**Solutions:**
1. Configure BatchSpanProcessor with appropriate limits:
```python
batch_processor = BatchSpanProcessor(
exporter,
max_queue_size=2048, # Reduce if memory is constrained
schedule_delay_millis=5000, # Increase to batch more spans
max_export_batch_size=512
)
```
2. Exclude high-volume endpoints:
```python
FastAPIInstrumentor.instrument_app(
app,
excluded_urls="/health,/metrics"
)
```
##### Issue: Middleware ordering problems
**Solution:** Ensure correct middleware order (metrics before instrumentation):
```python
app = FastAPI()
app.add_middleware(MetricsMiddleware) # Custom middleware first
FastAPIInstrumentor.instrument_app(app) # Then instrument
```
### Security Considerations
#### Sensitive Data in Spans
Avoid capturing sensitive information in span attributes:
**Bad Example:**
```python
# DON'T DO THIS
span.set_attribute("user.password", password)
span.set_attribute("credit_card.number", card_number)
span.set_attribute("user.ssn", ssn)
```
**Good Example:**
```python
# DO THIS INSTEAD
span.set_attribute("user.id", user_id) # Reference, not sensitive data
span.set_attribute("payment.method", "credit_card") # Type, not details
span.set_attribute("user.email_domain", email.split("@")[1]) # Partial info
```
#### HTTP Header Filtering
Filter sensitive headers from traces:
```python title="app/telemetry.py" showLineNumbers
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
# Exclude sensitive headers from capture
FastAPIInstrumentor.instrument_app(
app,
http_capture_headers_server_request=["content-type", "user-agent"],
# DO NOT include: authorization, cookie, api-key, etc.
)
```
#### SQL Query Obfuscation
SQLAlchemy instrumentation automatically obfuscates query parameters, but verify
this is enabled:
```python title="app/database.py" showLineNumbers
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
SQLAlchemyInstrumentor().instrument(
engine=engine,
enable_commenter=True,
# Query parameters are automatically obfuscated
)
```
#### Environment Variable Security
Never commit sensitive values to version control:
```bash title=".env" showLineNumbers
# ❌ BAD - Don't commit this file
SECRET_KEY=actual-secret-key
DB_PASSWORD=actual-password
# ✅ GOOD - Use secrets management in production
# AWS Secrets Manager, Vault, Kubernetes Secrets, etc.
```
In production, use environment-specific secrets management:
- AWS Secrets Manager
- HashiCorp Vault
- Kubernetes Secrets
- Azure Key Vault
### Performance Considerations
#### Expected Performance Impact
OpenTelemetry instrumentation adds minimal overhead when properly configured:
| Metric | Impact | Notes |
| ----------- | -------------------- | ------------------------- |
| **Latency** | +0.5-2ms per request | Mostly from span creation |
| **CPU** | +2-5% | Primarily during export |
| **Memory** | +10-50MB | BatchSpanProcessor queue |
| **Network** | +1-5KB per trace | OTLP compressed payload |
#### Optimization Strategies
##### 1. Use BatchSpanProcessor in Production
Always use `BatchSpanProcessor` (never `SimpleSpanProcessor`) for production:
```python title="app/telemetry.py" showLineNumbers
from opentelemetry.sdk.trace.export import BatchSpanProcessor
# ✅ GOOD - Batches spans for efficient export
batch_processor = BatchSpanProcessor(
exporter,
max_queue_size=2048,
schedule_delay_millis=5000,
max_export_batch_size=512
)
# ❌ BAD - Exports each span immediately (only for debugging)
# simple_processor = SimpleSpanProcessor(exporter)
```
##### 2. Skip Non-Critical Endpoints
Exclude health checks and metrics endpoints:
```python title="app/main.py" showLineNumbers
FastAPIInstrumentor.instrument_app(
app,
excluded_urls="/health,/metrics,/favicon.ico,/docs,/openapi.json"
)
```
##### 3. Limit Attribute Sizes
Prevent large attributes from consuming memory:
```python title="app/services/data_service.py" showLineNumbers
def add_safe_attribute(span, key: str, value: str, max_length: int = 256):
"""Add attribute with size limit"""
if isinstance(value, str) and len(value) > max_length:
value = value[:max_length] + "... (truncated)"
span.set_attribute(key, value)
# Usage
span = trace.get_current_span()
add_safe_attribute(span, "response.body", large_response)
```
##### 5. Optimize Database Instrumentation
For high-traffic endpoints with many queries:
```python title="app/database.py" showLineNumbers
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
# Disable commenter for performance (optional)
SQLAlchemyInstrumentor().instrument(
engine=engine,
enable_commenter=False, # Reduces overhead slightly
)
```
### FAQ
#### Does FastAPI instrumentation work with async/await?
Yes, OpenTelemetry fully supports FastAPI's async/await patterns. Context
propagation works automatically across async operations, ensuring parent-child
span relationships are maintained correctly.
#### What is the performance impact of OpenTelemetry on FastAPI?
Typical overhead is 0.5-2ms added latency per request, 2-5% CPU increase, and
10-50MB additional memory. This impact is minimal and acceptable for most
production applications.
#### Which Python and FastAPI versions are supported?
- **Python**: 3.9+ minimum (Python 3.13+ recommended for best performance)
- **FastAPI**: 0.100.0+ (0.115.6+ recommended for Pydantic v2)
- **OpenTelemetry**: SDK 1.20+ (1.29+ recommended, always use latest stable)
#### How do I instrument SQLAlchemy database queries?
Install `opentelemetry-instrumentation-sqlalchemy` and instrument your engine:
```python
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
SQLAlchemyInstrumentor().instrument(engine=engine)
```
All queries will automatically be traced with full SQL details.
#### How does distributed tracing work across microservices?
OpenTelemetry uses W3C Trace Context headers (`traceparent`, `tracestate`) to
propagate trace context between services. FastAPI instrumentation automatically
extracts and injects these headers, enabling distributed traces across your
entire system.
#### What's the difference between traces and metrics?
- **Traces**: Show individual request flows with detailed timing (e.g., "this
specific request took 150ms")
- **Metrics**: Aggregate measurements over time (e.g., "average response time is
120ms")
Use both: traces for debugging specific issues, metrics for monitoring overall
health.
#### How do I detect N+1 database queries in FastAPI with OpenTelemetry?
View the span hierarchy in base14 Scout. N+1 queries appear as many sequential
database spans under a single parent span. The trace visualization clearly shows
the query pattern, making N+1 issues obvious.
#### Can I use OpenTelemetry with Pydantic v2?
Yes, OpenTelemetry works with both Pydantic v1 and v2. FastAPI automatically
handles Pydantic model serialization, and instrumentation captures the HTTP
layer regardless of Pydantic version.
#### How do I instrument background tasks and Celery?
For FastAPI background tasks, manually create spans (see Background Tasks
section). For Celery, install `opentelemetry-instrumentation-celery`:
```bash
pip install opentelemetry-instrumentation-celery
```
#### Does OpenTelemetry instrumentation affect FastAPI WebSocket connections?
FastAPI WebSocket connections are automatically traced. Each WebSocket
connection creates a long-lived span that tracks the entire connection duration
and messages exchanged.
#### How do I handle multi-tenancy in traces?
Add tenant identification to resource attributes or span attributes:
```python
span.set_attribute("tenant.id", tenant_id)
span.set_attribute("tenant.name", tenant_name)
```
Then filter and query by tenant in base14 Scout.
### What's Next?
#### Advanced Topics
- [Python Custom Instrumentation](../custom-instrumentation/python.md) - Manual
spans, metrics, and advanced patterns
- [OpenTelemetry Collector Configuration](../../collector-setup/otel-collector-config.md)
\- Advanced collector features
#### base14 Scout Platform Features
- [Creating Alerts](../../../guides/creating-alerts-with-logx.md) - Set up
alerts based on traces and metrics
- [Dashboard Creation](../../../guides/create-your-first-dashboard.md) - Build
custom dashboards
#### Deployment and Operations
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) -
Local development environment
- [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md) -
Production Kubernetes deployment
- [Scout Exporter Configuration](../../collector-setup/scout-exporter.md) -
Configure authentication and endpoints
### Complete Example
Here's a complete production-ready FastAPI application with OpenTelemetry
instrumentation:
#### Complete requirements.txt
```plaintext title="requirements.txt" showLineNumbers
# Web framework
fastapi[all]==0.115.6
uvicorn[standard]==0.32.0
# Database
sqlalchemy==2.0.36
psycopg2-binary==2.9.10
alembic==1.14.0
# Authentication
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.20
# OpenTelemetry core
opentelemetry-api==1.29.0
opentelemetry-sdk==1.29.0
opentelemetry-exporter-otlp==1.29.0
# OpenTelemetry instrumentation
opentelemetry-instrumentation-fastapi==0.50b0
opentelemetry-instrumentation-sqlalchemy==0.50b0
opentelemetry-instrumentation-requests==0.50b0
opentelemetry-instrumentation-httpx==0.50b0
# Utilities
pydantic==2.10.3
pydantic-settings==2.6.1
python-dotenv==1.0.1
```
#### Complete telemetry.py
```python title="app/telemetry.py" showLineNumbers
import os
import socket
from opentelemetry import trace, metrics
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
def setup_telemetry(otel_endpoint: str = None):
"""
Initialize OpenTelemetry tracing and metrics with production-ready configuration.
Args:
otel_endpoint: OTLP collector endpoint (e.g., "localhost:4318")
"""
# Get configuration from environment
service_name = os.getenv("OTEL_SERVICE_NAME", "fastapi-app")
environment = os.getenv("ENVIRONMENT", "development")
otel_endpoint = otel_endpoint or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4318")
# Create comprehensive resource attributes
resource = Resource.create({
"service.name": service_name,
"service.version": os.getenv("APP_VERSION", "1.0.0"),
"service.namespace": os.getenv("SERVICE_NAMESPACE", "default"),
"deployment.environment": environment,
"environment": environment,
"service.instance.id": socket.gethostname(),
"host.name": socket.gethostname(),
})
# Configure trace provider
provider = TracerProvider(resource=resource)
# Add OTLP exporter for production/staging
if environment in ["production", "staging"]:
otlp_processor = BatchSpanProcessor(
OTLPSpanExporter(endpoint=f"http://{otel_endpoint}/v1/traces"),
max_queue_size=2048,
schedule_delay_millis=5000,
max_export_batch_size=512,
export_timeout_millis=30000
)
provider.add_span_processor(otlp_processor)
else:
# Add console exporter for development
console_processor = BatchSpanProcessor(ConsoleSpanExporter())
provider.add_span_processor(console_processor)
trace.set_tracer_provider(provider)
# Configure metrics provider
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=f"http://{otel_endpoint}/v1/metrics"),
export_interval_millis=5000
)
metrics.set_meter_provider(
MeterProvider(resource=resource, metric_readers=[metric_reader])
)
print(f"✅ OpenTelemetry initialized: {service_name} ({environment})")
```
#### Complete main.py
```python title="app/main.py" showLineNumbers
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from .telemetry import setup_telemetry
from .metrics_middleware import MetricsMiddleware
from .database import engine
from .routers import users, posts
# Initialize telemetry FIRST
otel_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4318")
setup_telemetry(otel_endpoint)
# Instrument SQLAlchemy
SQLAlchemyInstrumentor().instrument(engine=engine)
# Create FastAPI app
app = FastAPI(
title="FastAPI with OpenTelemetry",
version="1.0.0",
description="Production-ready FastAPI with full observability"
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Add custom metrics middleware
app.add_middleware(MetricsMiddleware)
# Instrument FastAPI (excludes health/metrics endpoints)
FastAPIInstrumentor.instrument_app(
app,
excluded_urls="/health,/metrics"
)
# Instrument HTTP clients
RequestsInstrumentor().instrument()
# Include routers
app.include_router(users.router, prefix="/api", tags=["users"])
app.include_router(posts.router, prefix="/api", tags=["posts"])
@app.get("/")
def root():
return {"message": "Hello World", "status": "operational"}
@app.get("/health")
def health_check():
from opentelemetry import trace
current_span = trace.get_current_span()
return {
"status": "healthy",
"tracing": "enabled" if current_span.is_recording() else "disabled"
}
```
#### Repository Link
A complete working example with database integration, authentication, and full
instrumentation is available at:
[GitHub: base-14/examples/python/fastapi-postgres](https://github.com/base-14/examples/tree/main/python/fastapi-postgres)
Once telemetry is flowing, you can
[monitor FastAPI performance in Scout APM](https://base14.io/scout/apm)
— track request latency, error rates, and database query traces across
all your endpoints.
### References
- [Official OpenTelemetry Python Documentation](https://opentelemetry.io/docs/languages/python/)
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
- [OpenTelemetry Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/)
- [Official Metrics Documentation](https://opentelemetry.io/docs/concepts/signals/metrics/)
- [Official Traces Documentation](https://opentelemetry.io/docs/concepts/signals/traces/)
### Related Guides
- [Django Instrumentation](./django.md) - Full-featured Python framework with ORM
- [Flask Instrumentation](./flask.md) - Lightweight Python web framework
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) - Set
up collector for local development
- [Python Custom Instrumentation](../custom-instrumentation/python.md) - Manual
spans and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
---
## Fastify OpenTelemetry Instrumentation - Complete APM Setup Guide
## Fastify
Implement OpenTelemetry instrumentation for Fastify applications to enable
comprehensive application performance monitoring (APM), distributed tracing, and
observability. This guide shows you how to auto-instrument your Fastify
application to collect traces and metrics from HTTP requests, database queries,
Redis operations, background jobs, and custom business logic using the
OpenTelemetry Node.js SDK with minimal code changes.
Fastify is a performance-focused alternative to [Express](./express.md), with a
minimal core similar to the edge-first [Hono](./hono.md).
Fastify applications benefit from automatic instrumentation of the framework
itself, as well as popular libraries including PostgreSQL (pg), Redis (IORedis),
BullMQ, Drizzle ORM, and dozens of commonly used Node.js components. With
OpenTelemetry, you can monitor production performance, debug slow requests,
trace distributed transactions across microservices, and identify database query
bottlenecks without significant code modifications. Fastify's plugin-based
architecture and high-performance design work seamlessly with OpenTelemetry's
context propagation, ensuring accurate parent-child span relationships across
async operations.
Whether you're implementing observability for the first time, migrating from
commercial APM solutions like DataDog or New Relic, or troubleshooting
performance issues in production, this guide provides production-ready
configurations and best practices for Fastify OpenTelemetry instrumentation.
You'll learn how to set up auto-instrumentation, configure custom spans for
business logic, optimize performance, and deploy with Docker.
:::tip TL;DR
Create a `telemetry.ts` file that initializes `NodeSDK` with
`getNodeAutoInstrumentations()` and import it as the very first line of your
entry point - this auto-instruments Fastify routes, PostgreSQL (`pg`), Redis
(IORedis), and HTTP clients without touching route code. Set
`OTEL_SERVICE_NAME` and `OTEL_EXPORTER_OTLP_ENDPOINT`, and propagate trace
context explicitly into BullMQ job data so worker spans link back to the
originating request.
:::
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Overview
This guide demonstrates how to:
- Set up OpenTelemetry instrumentation for Fastify applications
- Configure automatic request and response tracing for HTTP endpoints
- Instrument database operations with PostgreSQL auto-instrumentation
- Implement custom spans for business logic and external API calls
- Trace background jobs with BullMQ and propagate context to workers
- Configure production-ready telemetry with BatchSpanProcessor
- Export telemetry data to base14 Scout via OTLP
- Deploy instrumented applications with Docker and Docker Compose
- Troubleshoot common instrumentation issues
- Optimize performance impact in production environments
### Who This Guide Is For
This documentation is designed for:
- **Fastify developers**: implementing observability and distributed tracing
for the first time in Node.js applications
- **DevOps engineers**: deploying Fastify applications with production
monitoring requirements and container orchestration
- **Engineering teams**: migrating from DataDog, New Relic, or other commercial
APM solutions to open-source observability
- **Backend developers**: debugging performance issues, slow queries, or async
operation bottlenecks in Fastify services
- **Platform teams**: standardizing observability across multiple Fastify
microservices with consistent instrumentation patterns
### Prerequisites
Before starting, ensure you have:
- **Node.js 24.0.0 or later** installed (latest LTS recommended)
- **Fastify 5.0.0 or later** installed in your project
- **Scout Collector** configured and accessible from your application
- See [Docker Compose Setup](../../collector-setup/docker-compose-example.md)
for local development
- See [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md)
for production deployment
- **Basic understanding** of OpenTelemetry concepts (traces, spans, attributes)
- Access to npm for package installation
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version | Notes |
| ------------------------- | --------------- | ------------------- | --------------------------------- |
| **Node.js** | 24.0.0 | 24.x LTS | Latest LTS with ESM support |
| **Fastify** | 5.0.0 | 5.7.1+ | Latest v5 with improved hooks |
| **TypeScript** (optional) | 5.0.0 | 5.9.3+ | For type safety |
| **OpenTelemetry SDK** | 0.200.0 | 0.211+ | Core SDK for traces and metrics |
| **PostgreSQL** (optional) | 15.0 | 18.x | For database instrumentation |
| **Redis** (optional) | 7.0 | 8.x | For IORedis instrumentation |
| **Drizzle ORM** (optional)| 0.40.0 | 0.45.1+ | Type-safe SQL builder |
#### Supported Libraries
OpenTelemetry automatically instruments these commonly used libraries:
- **Web frameworks**: Fastify, HTTP/HTTPS
- **Databases**: PostgreSQL (pg), MySQL, SQLite
- **Caching**: Redis (IORedis), Memcached
- **Job Queues**: BullMQ
- **HTTP Clients**: axios, node-fetch, http/https
- **Logging**: Pino (with trace correlation)
### Installation
#### Core Packages
Install the required OpenTelemetry packages for Fastify instrumentation:
```bash showLineNumbers
npm install @opentelemetry/api
npm install @opentelemetry/sdk-node
npm install @opentelemetry/auto-instrumentations-node
npm install @opentelemetry/exporter-trace-otlp-http
npm install @opentelemetry/exporter-metrics-otlp-http
npm install @opentelemetry/resources
npm install @opentelemetry/semantic-conventions
```
#### Optional Instrumentation Libraries
Add these packages for specific component instrumentation:
```bash showLineNumbers
# Fastify-specific instrumentation (included in auto-instrumentations)
npm install @opentelemetry/instrumentation-fastify
# Logs export (optional)
npm install @opentelemetry/api-logs
npm install @opentelemetry/sdk-logs
npm install @opentelemetry/exporter-logs-otlp-http
# Pino trace correlation (optional)
npm install pino-opentelemetry-transport
```
### Configuration
Choose the initialization method that best fits your application architecture:
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
#### SDK Configuration File (Recommended)
Create a `telemetry.ts` file that initializes OpenTelemetry before any other
imports. This is the recommended approach for Fastify applications.
```typescript showLineNumbers title="src/telemetry.ts"
/**
* OpenTelemetry instrumentation setup for Fastify application.
*
* CRITICAL: This file MUST be imported before any other modules
* to ensure auto-instrumentation captures all dependencies.
*/
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions';
const otlpEndpoint =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318';
const serviceName = process.env.OTEL_SERVICE_NAME || 'fastify-app';
const serviceVersion = process.env.OTEL_SERVICE_VERSION || '1.0.0';
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: serviceName,
[ATTR_SERVICE_VERSION]: serviceVersion,
});
const sdk = new NodeSDK({
resource,
traceExporter: new OTLPTraceExporter({
url: `${otlpEndpoint}/v1/traces`,
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: `${otlpEndpoint}/v1/metrics`,
}),
exportIntervalMillis: 60000,
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
const url = req.url || '';
// Skip health checks and metrics endpoints
return url === '/health' || url === '/metrics';
},
},
'@opentelemetry/instrumentation-fs': {
enabled: false, // Disable noisy filesystem instrumentation
},
}),
],
});
sdk.start();
// Graceful shutdown
process.on('SIGTERM', () => {
sdk
.shutdown()
.then(() => console.log('OpenTelemetry SDK shut down'))
.catch((err) => console.error('Error shutting down SDK', err))
.finally(() => process.exit(0));
});
export { sdk };
```
Import this file as the first line in your application entry point:
```typescript showLineNumbers title="src/index.ts"
import './telemetry.js';
import { createApp } from './app.js';
import { config } from './config/index.js';
const start = async () => {
const app = await createApp();
await app.listen({ port: config.port, host: config.host });
app.log.info(`Server running at http://${config.host}:${config.port}`);
};
start();
```
```mdx-code-block
```
#### Environment Variables Only
For simpler setups or container deployments, configure via environment
variables:
```bash showLineNumbers title=".env"
OTEL_SERVICE_NAME=fastify-app
OTEL_SERVICE_VERSION=1.0.0
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
OTEL_NODE_RESOURCE_DETECTORS=env,host,os,process
```
Then use a minimal telemetry file:
```typescript showLineNumbers title="src/telemetry.ts"
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
const sdk = new NodeSDK({
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
```
```mdx-code-block
```
#### With Log Export
For complete observability including structured logs:
```typescript showLineNumbers title="src/telemetry.ts"
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions';
const otlpEndpoint =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318';
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'fastify-app',
[ATTR_SERVICE_VERSION]: process.env.OTEL_SERVICE_VERSION || '1.0.0',
});
const sdk = new NodeSDK({
resource,
traceExporter: new OTLPTraceExporter({
url: `${otlpEndpoint}/v1/traces`,
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: `${otlpEndpoint}/v1/metrics`,
}),
exportIntervalMillis: 60000,
}),
logRecordProcessor: new BatchLogRecordProcessor(
new OTLPLogExporter({
url: `${otlpEndpoint}/v1/logs`,
})
),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
```
```mdx-code-block
```
#### Scout Collector Integration
Configure for base14 Scout with OAuth2 authentication:
```yaml showLineNumbers title="config/otel-config.yaml"
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 512
resource:
attributes:
- key: deployment.environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
- key: environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
exporters:
otlphttp/scout:
endpoint: ${SCOUT_ENDPOINT}
auth:
authenticator: oauth2client
extensions:
oauth2client:
client_id: ${SCOUT_CLIENT_ID}
client_secret: ${SCOUT_CLIENT_SECRET}
token_url: ${SCOUT_TOKEN_URL}
health_check:
endpoint: 0.0.0.0:13133
service:
extensions: [oauth2client, health_check]
pipelines:
traces:
receivers: [otlp]
processors: [resource, batch]
exporters: [otlphttp/scout]
metrics:
receivers: [otlp]
processors: [resource, batch]
exporters: [otlphttp/scout]
logs:
receivers: [otlp]
processors: [resource, batch]
exporters: [otlphttp/scout]
```
```mdx-code-block
```
### Production Configuration
#### Resource Attributes
Configure resource attributes for production deployments:
```typescript showLineNumbers title="src/telemetry.ts"
import { resourceFromAttributes } from '@opentelemetry/resources';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
ATTR_SERVICE_NAMESPACE,
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
ATTR_HOST_NAME,
} from '@opentelemetry/semantic-conventions';
import os from 'os';
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'fastify-app',
[ATTR_SERVICE_VERSION]: process.env.OTEL_SERVICE_VERSION || '1.0.0',
[ATTR_SERVICE_NAMESPACE]: process.env.SERVICE_NAMESPACE || 'production',
[ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: process.env.NODE_ENV || 'development',
[ATTR_HOST_NAME]: os.hostname(),
'service.instance.id': process.env.HOSTNAME || crypto.randomUUID(),
});
```
#### Production Environment Variables
```bash showLineNumbers title=".env.production"
# Service identification
OTEL_SERVICE_NAME=fastify-api
OTEL_SERVICE_VERSION=1.2.3
SERVICE_NAMESPACE=production
# Collector endpoint
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
# Resource attributes
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=demo,environment=demo,service.namespace=api
# Sampling (reduce volume in high-traffic scenarios)
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
```
#### Docker Deployment
```dockerfile showLineNumbers title="Dockerfile"
# Build stage
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production=false
COPY . .
RUN npm run build
# Runtime stage
FROM node:24-alpine AS runtime
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Copy built application
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./
USER nodejs
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]
```
#### Docker Compose Configuration
```yaml showLineNumbers title="compose.yml"
services:
app:
build:
context: .
target: runtime
ports:
- "3000:3000"
environment:
NODE_ENV: production
PORT: "3000"
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/app
REDIS_URL: redis://redis:6379
OTEL_SERVICE_NAME: fastify-api
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_RESOURCE_ATTRIBUTES: deployment.environment=development,environment=development
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
otel-collector:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
- app-network
worker:
build:
context: .
target: runtime
command: ["node", "dist/jobs/worker.js"]
environment:
NODE_ENV: production
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/app
REDIS_URL: redis://redis:6379
OTEL_SERVICE_NAME: fastify-worker
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
depends_on:
- postgres
- redis
- otel-collector
networks:
- app-network
otel-collector:
image: otel/opentelemetry-collector-contrib:0.144.0
command: ["--config=/etc/otelcol-config.yaml"]
volumes:
- ./config/otel-config.yaml:/etc/otelcol-config.yaml:ro
ports:
- "4317:4317"
- "4318:4318"
env_file:
- .env
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:13133/health"]
interval: 10s
timeout: 5s
retries: 3
networks:
- app-network
networks:
app-network:
driver: bridge
```
### Framework-Specific Instrumentation
#### Fastify Hooks and Plugins
Fastify's hook system integrates naturally with OpenTelemetry:
```typescript showLineNumbers title="src/app.ts"
import Fastify, { FastifyInstance, FastifyError } from 'fastify';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import { trace } from '@opentelemetry/api';
export async function createApp(): Promise {
const fastify = Fastify({
logger: true,
requestIdLogLabel: 'requestId',
genReqId: () => crypto.randomUUID(),
});
// Security plugins
await fastify.register(helmet);
await fastify.register(cors, { origin: true });
await fastify.register(rateLimit, { max: 100, timeWindow: '1 minute' });
// Error handler with trace context
fastify.setErrorHandler((error: FastifyError, request, reply) => {
const span = trace.getActiveSpan();
const traceId = span?.spanContext()?.traceId;
fastify.log.error({ err: error, traceId }, 'Request error');
const statusCode = error.statusCode ?? 500;
reply.code(statusCode).send({
error: error.message,
statusCode,
...(traceId && { traceId }),
});
});
return fastify;
}
```
#### Automatic Route Instrumentation
OpenTelemetry automatically instruments Fastify routes:
```typescript showLineNumbers title="src/routes/articles.ts"
import { FastifyPluginAsync } from 'fastify';
import * as articleService from '../services/article.js';
const articlesRoutes: FastifyPluginAsync = async (fastify) => {
// GET /api/articles - automatically traced
fastify.get('/', async (request, reply) => {
const { limit, offset, author } = request.query as {
limit?: number;
offset?: number;
author?: string;
};
const result = await articleService.findArticles(
{ limit, offset, author },
request.user?.id
);
return result;
});
// POST /api/articles - automatically traced
fastify.post('/', { preHandler: [fastify.authenticate] }, async (request) => {
const { title, description, body } = request.body as {
title: string;
description?: string;
body: string;
};
return articleService.createArticle(request.user!.id, {
title,
description,
body,
});
});
};
export default articlesRoutes;
```
#### PostgreSQL with Drizzle ORM
Database queries are automatically instrumented:
```typescript showLineNumbers title="src/db/index.ts"
import { drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import * as schema from './schema.js';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
export const db = drizzle(pool, { schema });
export async function closeDatabase(): Promise {
await pool.end();
}
```
### Custom Instrumentation
#### Business Logic Spans
Add custom spans for business-critical operations:
```typescript showLineNumbers title="src/services/article.ts"
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { db } from '../db/index.js';
import { articles, users } from '../db/schema.js';
const tracer = trace.getTracer('article-service');
export async function createArticle(
authorId: number,
input: { title: string; description?: string; body: string }
) {
return tracer.startActiveSpan('article.create', async (span) => {
try {
span.setAttribute('user.id', authorId);
const slug = generateSlug(input.title);
const [newArticle] = await db
.insert(articles)
.values({
slug,
title: input.title,
description: input.description || null,
body: input.body,
authorId,
})
.returning();
span.setAttribute('article.id', newArticle.id);
span.setAttribute('article.slug', newArticle.slug);
span.setStatus({ code: SpanStatusCode.OK });
// Enqueue background job (fire and forget)
enqueueNotification(newArticle).catch((err) => {
console.error('Failed to enqueue notification', err);
});
return newArticle;
} catch (error) {
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: (error as Error).message,
});
throw error;
} finally {
span.end();
}
});
}
```
#### External API Calls
Instrument external service calls with error handling:
```typescript showLineNumbers title="src/services/external.ts"
import { trace, SpanStatusCode, SpanKind } from '@opentelemetry/api';
const tracer = trace.getTracer('external-service');
export async function fetchExternalData(resourceId: string): Promise {
return tracer.startActiveSpan(
'external.fetch',
{ kind: SpanKind.CLIENT },
async (span) => {
try {
span.setAttribute('external.resource_id', resourceId);
span.setAttribute('http.url', `https://api.example.com/${resourceId}`);
const response = await fetch(
`https://api.example.com/resources/${resourceId}`,
{
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
}
);
span.setAttribute('http.status_code', response.status);
if (!response.ok) {
throw new Error(`External API error: ${response.status}`);
}
const data = await response.json();
span.setStatus({ code: SpanStatusCode.OK });
return data;
} catch (error) {
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: (error as Error).message,
});
throw error;
} finally {
span.end();
}
}
);
}
```
#### Background Job Tracing with BullMQ
Propagate trace context to background workers:
```typescript showLineNumbers title="src/jobs/tasks/notification.ts"
import { Queue } from 'bullmq';
import { context, propagation } from '@opentelemetry/api';
const notificationQueue = new Queue('notifications', {
connection: { host: 'localhost', port: 6379 },
});
interface ArticleCreatedData {
articleId: number;
articleSlug: string;
authorId: number;
authorName: string;
title: string;
traceContext?: Record;
}
export async function enqueueArticleCreatedNotification(
data: Omit
): Promise {
// Capture current trace context
const traceContext: Record = {};
propagation.inject(context.active(), traceContext);
await notificationQueue.add('article-created', {
...data,
traceContext,
});
}
```
Process jobs with trace context restoration:
```typescript showLineNumbers title="src/jobs/worker.ts"
import '../telemetry.js';
import { Worker, Job } from 'bullmq';
import { trace, context, propagation, SpanStatusCode } from '@opentelemetry/api';
const tracer = trace.getTracer('notification-worker');
const worker = new Worker(
'notifications',
async (job) => {
// Restore parent trace context
const parentContext = job.data.traceContext
? propagation.extract(context.active(), job.data.traceContext)
: context.active();
return context.with(parentContext, async () => {
return tracer.startActiveSpan(
`job.${job.name}`,
{
attributes: {
'job.id': job.id || 'unknown',
'job.name': job.name,
'job.queue': 'notifications',
'job.attempt': job.attemptsMade + 1,
},
},
async (span) => {
try {
// Process the job
await processJob(job);
span.setStatus({ code: SpanStatusCode.OK });
} catch (error) {
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: (error as Error).message,
});
throw error;
} finally {
span.end();
}
}
);
});
},
{ connection: { host: 'localhost', port: 6379 }, concurrency: 5 }
);
```
### Running Your Application
#### Development Mode
Run with console output for debugging:
```bash showLineNumbers
# Start the application
npm run dev
# In a separate terminal, start the worker
npm run dev:worker
```
#### Production Mode
```bash showLineNumbers
# Build
npm run build
# Start with environment variables
NODE_ENV=production \
OTEL_SERVICE_NAME=fastify-api \
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 \
node dist/index.js
```
#### Docker Deployment
```bash showLineNumbers
# Build and run with Docker Compose
docker compose up --build
# View logs
docker compose logs -f app worker
# Stop services
docker compose down
```
### Troubleshooting
#### Verification Test
Test that instrumentation is working:
```typescript showLineNumbers title="scripts/verify-telemetry.ts"
import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('verification');
async function verify() {
const span = tracer.startSpan('verification.test');
span.setAttribute('test.attribute', 'value');
console.log('Trace ID:', span.spanContext().traceId);
span.end();
}
verify();
```
#### Health Check Endpoint
Implement a health check that includes telemetry status:
```typescript showLineNumbers title="src/routes/health.ts"
import { FastifyPluginAsync } from 'fastify';
import { trace } from '@opentelemetry/api';
import { db } from '../db/index.js';
const healthRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/health', async () => {
const span = trace.getActiveSpan();
const traceId = span?.spanContext()?.traceId;
// Check database connectivity
try {
await db.execute('SELECT 1');
} catch (error) {
return {
status: 'unhealthy',
database: 'disconnected',
traceId,
};
}
return {
status: 'healthy',
database: 'connected',
traceId,
timestamp: new Date().toISOString(),
};
});
};
export default healthRoutes;
```
#### Debug Mode
Enable verbose OpenTelemetry logging:
```bash showLineNumbers
# Enable debug logging
OTEL_LOG_LEVEL=debug npm run dev
```
#### Common Issues
##### Issue: No traces appearing in Scout
**Solutions:**
1. Verify collector connectivity:
```bash
curl -f http://localhost:4318/v1/traces
```
2. Check environment variables are set correctly
3. Ensure telemetry.ts is imported first in index.ts
4. Verify Scout credentials in collector config
##### Issue: Missing database spans
**Solutions:**
1. Ensure `@opentelemetry/auto-instrumentations-node` is installed
2. Verify the pg driver is being used (not pg-native)
3. Check that telemetry initialization happens before database import
##### Issue: Background job traces not linked
**Solutions:**
1. Verify trace context is being propagated to job data
2. Ensure worker imports telemetry.ts before other modules
3. Check that context.with() wraps the job processor
##### Issue: High memory usage
**Solutions:**
1. Reduce batch size in exporter configuration
2. Enable sampling for high-traffic endpoints
3. Disable filesystem instrumentation
### Security Considerations
#### Sensitive Data Protection
Avoid capturing sensitive information in spans:
```typescript showLineNumbers title="src/services/auth.ts"
// BAD: Captures password
span.setAttribute('user.password', password);
// GOOD: Only capture non-sensitive identifiers
span.setAttribute('user.id', userId);
span.setAttribute('user.email_domain', email.split('@')[1]);
```
#### SQL Query Obfuscation
Configure database instrumentation to obfuscate queries:
```typescript showLineNumbers title="src/telemetry.ts"
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-pg': {
enhancedDatabaseReporting: false, // Don't include query parameters
},
}),
],
```
#### HTTP Header Filtering
Filter sensitive headers from HTTP spans:
```typescript showLineNumbers title="src/telemetry.ts"
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': {
headersToSpanAttributes: {
server: {
requestHeaders: ['x-request-id', 'user-agent'],
responseHeaders: ['x-request-id'],
},
},
},
}),
],
```
#### Compliance Considerations
For GDPR, HIPAA, or PCI-DSS compliance:
- Never log PII (names, emails, addresses) in span attributes
- Use pseudonymization for user identifiers when possible
- Configure data retention policies in your observability backend
- Implement attribute filtering at the collector level
### Performance Considerations
#### Expected Impact
| Metric | Typical Impact | High-Traffic Impact |
| ------------- | -------------- | ------------------- |
| Latency | +1-3ms | +2-5ms |
| CPU overhead | 2-5% | 5-10% |
| Memory | +50-100MB | +100-200MB |
#### Impact Factors
- Number of spans per request
- Attribute count and size
- Batch export frequency
- Sampling configuration
#### Optimization Best Practices
##### 1. Use Sampling in Production
```typescript showLineNumbers title="src/telemetry.ts"
import { TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base';
const sdk = new NodeSDK({
sampler: new TraceIdRatioBasedSampler(0.1), // Sample 10% of traces
// ...
});
```
##### 2. Skip Non-Critical Endpoints
```typescript showLineNumbers
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
const url = req.url || '';
return url === '/health' || url === '/metrics' || url === '/favicon.ico';
},
},
```
##### 3. Limit Attribute Sizes
```typescript showLineNumbers
span.setAttribute('request.body', JSON.stringify(body).slice(0, 1000));
```
##### 4. Configure Batch Export
```typescript showLineNumbers
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
const batchProcessor = new BatchSpanProcessor(traceExporter, {
maxQueueSize: 2048,
maxExportBatchSize: 512,
scheduledDelayMillis: 5000,
exportTimeoutMillis: 30000,
});
```
##### 5. Disable Unnecessary Instrumentations
```typescript showLineNumbers
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false },
'@opentelemetry/instrumentation-dns': { enabled: false },
'@opentelemetry/instrumentation-net': { enabled: false },
}),
],
```
### FAQ
#### What is the performance impact of OpenTelemetry on Fastify?
OpenTelemetry typically adds 1-3ms latency per request with 2-5% CPU overhead.
For high-traffic applications, use sampling to reduce this impact. The
BatchSpanProcessor helps minimize overhead by buffering spans and exporting
them in batches.
#### Which versions of Fastify are supported?
OpenTelemetry instrumentation supports Fastify 3.x and later. This guide
focuses on Fastify 5.x which provides improved hooks and TypeScript support.
For older versions, the same instrumentation approach works with minor
adjustments.
#### How do I instrument Fastify with PostgreSQL and Drizzle ORM?
PostgreSQL queries are automatically instrumented through the `pg` driver.
Drizzle ORM uses `pg` under the hood, so all queries are captured as database
spans. No additional configuration is needed beyond the standard
auto-instrumentation setup.
#### How do I reduce trace volume in production?
Use sampling to capture a percentage of traces:
```typescript
sampler: new TraceIdRatioBasedSampler(0.1) // 10% sampling
```
You can also exclude health checks and static assets from tracing using
`ignoreIncomingRequestHook`.
#### How do I handle multi-tenancy in traces?
Add tenant context as span attributes:
```typescript
span.setAttribute('tenant.id', request.headers['x-tenant-id']);
span.setAttribute('tenant.name', tenantName);
```
This allows filtering traces by tenant in Scout Dashboard.
#### What's the difference between traces and metrics?
**Traces** capture the journey of individual requests through your system,
showing timing and relationships between operations. Use traces for debugging
specific requests and understanding request flow.
**Metrics** are aggregated measurements over time (counters, gauges, histograms).
Use metrics for dashboards, alerting, and capacity planning.
#### How do I debug slow database queries with OpenTelemetry?
Database spans include query timing and (optionally) the SQL statement. In
Scout Dashboard, filter spans by `db.system = postgresql` and sort by duration
to find slow queries. The span attributes include table names and operation
types.
#### How do I trace background jobs with BullMQ?
Inject trace context when enqueuing jobs and extract it in the worker:
```typescript
// Producer: inject context
propagation.inject(context.active(), jobData.traceContext);
// Consumer: extract and restore context
const parentContext = propagation.extract(context.active(), job.data.traceContext);
context.with(parentContext, () => { /* process job */ });
```
#### Can I use OpenTelemetry with Fastify plugins?
Yes, plugins are automatically instrumented as part of the request lifecycle.
Custom plugin operations can be wrapped in spans using the tracer API for
additional visibility.
#### How do I correlate Pino logs with traces?
Use `pino-opentelemetry-transport` to automatically inject trace IDs into log
entries:
```typescript
import pino from 'pino';
const logger = pino({
transport: {
targets: [
{ target: 'pino-opentelemetry-transport', level: 'info' },
{ target: 'pino-pretty', level: 'debug' },
],
},
});
```
#### How do I export metrics to Prometheus?
OpenTelemetry metrics can be exported to Prometheus via the collector or
directly using the Prometheus exporter. For Fastify applications, you can
also expose a `/metrics` endpoint using `prom-client` alongside OpenTelemetry
metrics.
### What's Next?
#### Related Guides
- [NestJS Instrumentation](./nestjs.md) - Structured framework built on Fastify
- [Node.js Custom Instrumentation](../custom-instrumentation/javascript-node.md)
\- Manual spans and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
#### Scout Platform Features
- [Creating Alerts](../../../guides/creating-alerts-with-logx.md) - Set up
alerting for Fastify services
- [Dashboard Creation](../../../guides/create-your-first-dashboard.md) - Build
custom dashboards
#### Deployment and Operations
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) -
Local development configuration
- [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md) -
Production deployment
### Complete Example
#### Project Structure
```plain
fastify-postgres/
├── src/
│ ├── telemetry.ts # OpenTelemetry initialization (import first!)
│ ├── index.ts # Application entry point
│ ├── app.ts # Fastify app configuration
│ ├── config/
│ │ └── index.ts # Environment configuration
│ ├── db/
│ │ ├── index.ts # Database connection
│ │ └── schema.ts # Drizzle schema
│ ├── routes/
│ │ ├── health.ts # Health check endpoint
│ │ ├── auth.ts # Authentication routes
│ │ └── articles.ts # Article CRUD routes
│ ├── services/
│ │ ├── article.ts # Article business logic
│ │ └── redis.ts # Redis client
│ └── jobs/
│ ├── queue.ts # BullMQ queue setup
│ ├── worker.ts # Background worker
│ └── tasks/
│ └── notification.ts
├── config/
│ └── otel-config.yaml # Collector configuration
├── compose.yml # Docker Compose
├── Dockerfile
├── package.json
└── tsconfig.json
```
#### Dependencies
```json showLineNumbers title="package.json"
{
"name": "fastify-postgres",
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=24.0.0"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/helmet": "^13.0.2",
"@fastify/jwt": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.69.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.211.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
"@opentelemetry/resources": "^2.4.0",
"@opentelemetry/sdk-metrics": "^2.4.0",
"@opentelemetry/sdk-node": "^0.211.0",
"@opentelemetry/semantic-conventions": "^1.39.0",
"bullmq": "^5.66.7",
"drizzle-orm": "^0.45.1",
"fastify": "^5.7.1",
"ioredis": "^5.9.2",
"pg": "^8.17.2",
"pino": "^10.3.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
```
#### GitHub Repository
For a complete working example, see the
[Fastify PostgreSQL Example](https://github.com/base-14/examples/tree/main/nodejs/fastify-postgres)
repository.
### References
- [Official OpenTelemetry Node.js Documentation](https://opentelemetry.io/docs/languages/js/)
- [Fastify Documentation](https://fastify.dev/docs/latest/)
- [OpenTelemetry Fastify Instrumentation](https://www.npmjs.com/package/@opentelemetry/instrumentation-fastify)
- [BullMQ Documentation](https://docs.bullmq.io/)
- [Drizzle ORM Documentation](https://orm.drizzle.team/)
---
## Flask OpenTelemetry Instrumentation - SQLAlchemy & Celery Tracing
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Introduction
Flask is a lightweight WSGI web application framework designed to make getting
started quick and easy, with the ability to scale up to complex applications.
As a micro-framework, Flask provides the essentials for building web
applications without imposing rigid structure, making it ideal for APIs,
microservices, and rapid prototyping.
Flask is a lightweight WSGI microframework. [Django](./django.md) is the
batteries-included alternative, while [FastAPI](./fast-api.md) and
[Litestar](./litestar.md) are modern async-first options.
This guide demonstrates how to instrument Flask applications with OpenTelemetry
for comprehensive distributed tracing, metrics collection, and application
performance monitoring. We'll cover automatic instrumentation of Flask routes,
SQLAlchemy queries, blueprint-based applications, and Celery background
tasks—all while maintaining Flask's minimalist philosophy and flexibility.
Unlike Django's batteries-included approach with automatic middleware injection,
Flask instrumentation requires explicit initialization in your application
factory or startup code. This manual approach provides fine-grained control over
what gets traced and how, making it ideal for microservices architectures where
minimal overhead is critical. We'll explore both automatic and custom
instrumentation patterns, including Flask-specific considerations like request
context propagation, blueprint isolation, and extension compatibility.
:::tip TL;DR
Initialize `FlaskInstrumentor` inside your `create_app()` factory, then
instrument SQLAlchemy and Celery separately after each extension is initialized.
Use the OTLP exporter to send spans to base14 Scout with no changes to your
route logic.
:::
### Who This Guide Is For
This guide is designed for:
- **Flask Developers** building RESTful APIs and microservices requiring
lightweight tracing with minimal performance overhead
- **API Engineers** working with Flask-RESTful or Flask-RESTX and needing
endpoint-level observability across distributed services
- **Microservices Teams** deploying Flask applications in containers and
requiring distributed tracing across service boundaries
- **Backend Developers** using Flask with SQLAlchemy and needing query-level
performance insights without ORM overhead
- **Technical Leads** implementing observability in Flask applications using the
application factory pattern and blueprints
### Overview
This guide covers Flask OpenTelemetry instrumentation using the official
OpenTelemetry Python SDK and Flask-specific auto-instrumentation packages. The
approach leverages Flask's request hooks and context locals for comprehensive
tracing.
#### What You'll Learn
- Installing and configuring OpenTelemetry SDK for Flask with automatic
instrumentation
- Instrumenting Flask routes and blueprints with minimal code changes
- Tracing SQLAlchemy queries with full SQL visibility and parameter binding
- Setting up application factory pattern with centralized tracing initialization
- Implementing Celery distributed tracing for background tasks
- Tracing Flask extensions (Flask-Login, Flask-JWT-Extended, Flask-CORS)
- Managing request context propagation across blueprints and utilities
- Detecting slow database queries and N+1 patterns using span attributes
- Optimizing telemetry overhead for high-throughput Flask APIs
- Deploying instrumented Flask apps with Gunicorn, uWSGI, or gevent workers
#### Prerequisites
**System Requirements:**
- **Python:** 3.9+ (3.13+ recommended for latest features)
- **Flask:** 2.0+ (3.0+ recommended)
- **PostgreSQL:** 12+ (18+ recommended) or other supported databases
- **Celery:** 5.0+ for background task tracing (optional)
- **Redis:** 6.0+ for caching and Celery broker (optional)
**Supported Flask Versions:**
| Flask Version | Python Version | OpenTelemetry Support | Status |
| ------------- | -------------- | --------------------- | ----------- |
| 3.1+ | 3.9+ | ✅ Full | Recommended |
| 3.0 | 3.8+ | ✅ Full | Current |
| 2.3 | 3.8+ | ✅ Full | Supported |
| 2.0-2.2 | 3.7+ | ✅ Full | Legacy |
| 1.1 | 3.5+ | ⚠️ Limited | EOL |
| 0.x | 2.7+ | ❌ Not supported | EOL |
**Instrumented Components:**
OpenTelemetry Flask instrumentation automatically traces:
- ✅ **HTTP Requests** - All Flask routes and blueprints
- ✅ **Database Queries** - SQLAlchemy ORM and Core queries
- ✅ **Template Rendering** - Jinja2 template execution
- ✅ **Cache Operations** - Redis and Memcached operations
- ✅ **Celery Tasks** - Background task execution with distributed context
- ✅ **HTTP Clients** - Requests library and httpx calls
- ✅ **Extensions** - Flask-Login, Flask-JWT-Extended, Flask-CORS
- ✅ **Before/After Request Hooks** - Flask request lifecycle
- ✅ **Error Handlers** - Exception handling and error responses
- ✅ **Blueprints** - Modular application components
:::info Example Application
This guide references the
[flask-postgres example](https://github.com/base-14/examples/tree/main/python/flask-postgres)
featuring:
- **Framework**: Flask 3.1+ with application factory pattern
- **Database**: PostgreSQL 18 with SQLAlchemy 2.0
- **Background Jobs**: Celery 5.4+ with Redis broker
- **Features**: Blueprint-based architecture, JWT authentication, PII masking
- **Deployment**: Gunicorn WSGI server with Docker and Kubernetes
:::
### Installation & Setup
Flask OpenTelemetry instrumentation requires the core SDK and Flask-specific
auto-instrumentation packages. The setup process installs dependencies and
initializes tracing in your application factory.
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
**pip (Recommended)**
Install OpenTelemetry SDK and Flask instrumentation:
```bash title="Terminal" showLineNumbers
# Install core OpenTelemetry SDK
pip install opentelemetry-api opentelemetry-sdk
# Install Flask auto-instrumentation
pip install opentelemetry-instrumentation-flask
# Install SQLAlchemy instrumentation
pip install opentelemetry-instrumentation-sqlalchemy
# Install Celery instrumentation (optional)
pip install opentelemetry-instrumentation-celery
# Install Redis instrumentation (optional)
pip install opentelemetry-instrumentation-redis
# Install Requests instrumentation (optional)
pip install opentelemetry-instrumentation-requests
# Install OTLP exporter
pip install opentelemetry-exporter-otlp
# Freeze dependencies
pip freeze > requirements.txt
```
**Poetry**
Add dependencies to `pyproject.toml`:
```toml title="pyproject.toml" showLineNumbers
[tool.poetry.dependencies]
python = "^3.10"
flask = "^3.1"
sqlalchemy = "^2.0"
psycopg2-binary = "^2.9"
celery = "^5.4"
redis = "^5.0"
# OpenTelemetry dependencies
opentelemetry-api = "^1.27"
opentelemetry-sdk = "^1.27"
opentelemetry-instrumentation-flask = "^0.48b0"
opentelemetry-instrumentation-sqlalchemy = "^0.48b0"
opentelemetry-instrumentation-celery = "^0.48b0"
opentelemetry-instrumentation-redis = "^0.48b0"
opentelemetry-instrumentation-requests = "^0.48b0"
opentelemetry-exporter-otlp = "^1.27"
```
Install dependencies:
```bash
poetry install
```
**Pipenv**
Add to `Pipfile`:
```toml title="Pipfile" showLineNumbers
[packages]
flask = "~=3.1"
sqlalchemy = "~=2.0"
psycopg2-binary = "~=2.9"
celery = "~=5.4"
redis = "~=5.0"
opentelemetry-api = "~=1.27"
opentelemetry-sdk = "~=1.27"
opentelemetry-instrumentation-flask = "~=0.48b0"
opentelemetry-instrumentation-sqlalchemy = "~=0.48b0"
opentelemetry-instrumentation-celery = "~=0.48b0"
opentelemetry-instrumentation-redis = "~=0.48b0"
opentelemetry-instrumentation-requests = "~=0.48b0"
opentelemetry-exporter-otlp = "~=1.27"
```
Install:
```bash
pipenv install
```
**Auto-Instrumentation Bootstrap**
Use the OpenTelemetry bootstrap command:
```bash title="Terminal" showLineNumbers
# Install bootstrap tool
pip install opentelemetry-bootstrap
# Auto-detect and install instrumentation
opentelemetry-bootstrap -a install
# This automatically installs:
# - opentelemetry-instrumentation-flask
# - opentelemetry-instrumentation-sqlalchemy
# - opentelemetry-instrumentation-celery
# - opentelemetry-instrumentation-redis
# (based on detected packages)
```
:::tip Flask Application Factory Pattern
Flask best practices recommend using the application factory pattern for
scalability and testing. This guide demonstrates tracing initialization within
the factory function for centralized configuration.
:::
### Configuration
Flask OpenTelemetry configuration involves initializing the SDK and
instrumenting your Flask application instance. Unlike Django, Flask requires
explicit instrumentation calls.
#### Application Factory with Tracing
Create an application factory with integrated tracing:
```python title="app/__init__.py" showLineNumbers
"""Flask application factory with OpenTelemetry tracing."""
import os
from flask import Flask
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
def create_app(config_name='default'):
"""Create and configure Flask application with tracing."""
app = Flask(__name__)
# Load configuration
app.config.from_object(f'config.{config_name.capitalize()}Config')
# Initialize OpenTelemetry
initialize_tracing(app)
# Initialize extensions
from app.extensions import db, migrate, redis_client
db.init_app(app)
migrate.init_app(app, db)
# Register blueprints
from app.routes.orders import orders_bp
from app.routes.health import health_bp
app.register_blueprint(orders_bp, url_prefix='/api/orders')
app.register_blueprint(health_bp, url_prefix='/health')
return app
def initialize_tracing(app):
"""Initialize OpenTelemetry tracing for Flask application."""
# Create resource with service information
resource = Resource.create({
"service.name": os.getenv("OTEL_SERVICE_NAME", "flask-order-service"),
"service.version": os.getenv("APP_VERSION", "1.0.0"),
"deployment.environment.name": os.getenv("ENVIRONMENT", "development"),
"environment": os.getenv("ENVIRONMENT", "development"),
})
# Create tracer provider
provider = TracerProvider(resource=resource)
# Configure OTLP exporter
otlp_exporter = OTLPSpanExporter(
endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
insecure=os.getenv("OTEL_EXPORTER_OTLP_INSECURE", "true") == "true",
)
# Add batch span processor
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
# Set as global tracer provider
trace.set_tracer_provider(provider)
# Instrument Flask application
FlaskInstrumentor().instrument_app(app)
# Instrument SQLAlchemy
from app.extensions import db
SQLAlchemyInstrumentor().instrument(
engine=db.engine,
enable_commenter=True,
)
# Instrument Redis
RedisInstrumentor().instrument()
app.logger.info(f"OpenTelemetry initialized: {resource.attributes.get('service.name')}")
```
#### Configuration Classes
```python title="config.py" showLineNumbers
"""Flask configuration classes."""
import os
from datetime import timedelta
class Config:
"""Base configuration."""
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-change-in-production')
# Database
SQLALCHEMY_DATABASE_URI = os.getenv(
'DATABASE_URL',
'postgresql://postgres:postgres@localhost:5432/orders'
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = os.getenv('SQLALCHEMY_ECHO', 'False') == 'True'
# Redis
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
# Celery
CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL', 'redis://localhost:6379/0')
CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0')
# OpenTelemetry
OTEL_SERVICE_NAME = os.getenv('OTEL_SERVICE_NAME', 'flask-order-service')
OTEL_EXPORTER_OTLP_ENDPOINT = os.getenv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4317')
class DevelopmentConfig(Config):
"""Development configuration."""
DEBUG = True
SQLALCHEMY_ECHO = True
class ProductionConfig(Config):
"""Production configuration."""
DEBUG = False
SQLALCHEMY_ECHO = False
# Production-specific settings
SQLALCHEMY_POOL_SIZE = 20
SQLALCHEMY_POOL_RECYCLE = 3600
class TestingConfig(Config):
"""Testing configuration."""
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
OTEL_SDK_DISABLED = True # Disable tracing in tests
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig
}
```
#### Environment Variables
```bash title=".env" showLineNumbers
# Flask configuration
FLASK_APP=wsgi.py
FLASK_ENV=development
SECRET_KEY=your-secret-key-here
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/orders
SQLALCHEMY_ECHO=False
# Redis
REDIS_URL=redis://localhost:6379/0
# Celery
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
# OpenTelemetry
OTEL_SERVICE_NAME=flask-order-service
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
OTEL_EXPORTER_OTLP_INSECURE=true
OTEL_SEMCONV_STABILITY_OPT_IN=http,database
APP_VERSION=1.0.0
ENVIRONMENT=development
```
`OTEL_SEMCONV_STABILITY_OPT_IN=http,database` opts the instrumentation into the
stable HTTP and database semantic conventions (for example
`http.request.method`, `http.response.status_code`, and `db.query.text`).
Without it, the instrumentation keeps emitting the older experimental attribute
names (`http.method`, `http.status_code`, `db.statement`). Use `http/dup` and
`database/dup` instead to emit both old and new during a migration.
#### WSGI Entry Point
```python title="wsgi.py" showLineNumbers
"""WSGI entry point for Flask application."""
import os
from app import create_app
# Create application instance
config_name = os.getenv('FLASK_ENV', 'development')
app = create_app(config_name)
if __name__ == '__main__':
# Development server with auto-reload
app.run(
host='0.0.0.0',
port=int(os.getenv('PORT', 5000)),
debug=os.getenv('DEBUG', 'True') == 'True'
)
```
#### Docker Compose Configuration
```yaml title="docker-compose.yml" showLineNumbers
version: '3.9'
services:
flask-app:
build: .
command: gunicorn wsgi:app --bind 0.0.0.0:5000 --workers 4
ports:
- '5000:5000'
environment:
FLASK_ENV: production
OTEL_SERVICE_NAME: flask-order-service
OTEL_EXPORTER_OTLP_ENDPOINT: http://scout-collector:4317
DATABASE_URL: postgresql://flask:flask123@postgres:5432/orders
REDIS_URL: redis://redis:6379/0
CELERY_BROKER_URL: redis://redis:6379/0
depends_on:
- postgres
- redis
- scout-collector
celery-worker:
build: .
command: celery -A app.celery_app worker --loglevel=info
environment:
FLASK_ENV: production
OTEL_SERVICE_NAME: flask-celery-worker
OTEL_EXPORTER_OTLP_ENDPOINT: http://scout-collector:4317
DATABASE_URL: postgresql://flask:flask123@postgres:5432/orders
REDIS_URL: redis://redis:6379/0
CELERY_BROKER_URL: redis://redis:6379/0
depends_on:
- postgres
- redis
- scout-collector
postgres:
image: postgres:18-alpine
environment:
POSTGRES_DB: orders
POSTGRES_USER: flask
POSTGRES_PASSWORD: flask123
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- '5432:5432'
redis:
image: redis:7-alpine
ports:
- '6379:6379'
scout-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ['--config=/etc/otel-collector-config.yaml']
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- '4317:4317'
volumes:
postgres_data:
```
:::info Scout Integration
When using [Base14 Scout](https://base14.io/scout), configure the OTLP endpoint
to point to your Scout Collector with authentication headers. Scout provides
managed infrastructure optimized for Flask microservices.
:::
### Traces
Traces follow a request through your Flask application, from the incoming route,
through view functions and blueprints, into SQLAlchemy queries, Redis calls, and
Celery task dispatch, and back out as the response.
#### Automatic Trace Collection
Once `FlaskInstrumentor` (plus the SQLAlchemy, Redis, and Celery instrumentors)
is applied, every request is traced with no per-view code. Outbound HTTP calls
need their own instrumentor (`RequestsInstrumentor` / `URLLib3Instrumentor`):
**Captured Information:**
- HTTP method, route rule, and status code for each view
- Request duration and a span-by-span timing breakdown
- SQLAlchemy queries, including the executed SQL (`SQLAlchemyInstrumentor`)
- Redis commands (`RedisInstrumentor`)
- Celery task enqueue and execution spans (`CeleryInstrumentor`)
- Exceptions recorded on the failing span with stack traces
- Distributed context propagation across services (W3C Trace Context)
**Trace Hierarchy:**
```text
HTTP Request Span (root: GET /api/orders/)
├── orders.get_order Span
│ ├── SQLAlchemy Query Span (SELECT ... FROM orders)
│ └── Redis GET Span (cache lookup)
└── Celery Enqueue Span (send_receipt task)
```
#### Key Tracing Features
- **Automatic HTTP tracking**: every route is traced with no code changes
- **SQLAlchemy visibility**: ORM and Core queries appear as child spans with the
executed SQL
- **Error capturing**: unhandled exceptions and error handlers are recorded with
full stack traces
- **Context propagation**: distributed traces follow requests across HTTP and
Celery boundaries
- **Blueprint support**: views registered on blueprints are traced with their
resolved route rule
> View traces in your base14 Scout dashboard to follow request flows and find
> the slow span in a chain.
##### Reference
[Official Traces Documentation](https://opentelemetry.io/docs/concepts/signals/traces/)
### Metrics
Metrics aggregate runtime measurements over time, such as request rate, latency
distributions, and error counts. Where traces explain a single request, metrics
power dashboards and alerts across all of them.
#### Enable the Meter Provider
Configure a `MeterProvider` with an OTLP exporter alongside your tracer setup so
metrics are exported to Scout:
```python title="app/telemetry.py" showLineNumbers
import os
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
OTLPMetricExporter,
)
# Same OTLP endpoint and gRPC transport as the tracer setup above
resource = Resource.create(
{"service.name": os.getenv("OTEL_SERVICE_NAME", "flask-order-service")}
)
reader = PeriodicExportingMetricReader(
OTLPMetricExporter(
endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
insecure=os.getenv("OTEL_EXPORTER_OTLP_INSECURE", "true") == "true",
),
export_interval_millis=15000,
)
metrics.set_meter_provider(MeterProvider(resource=resource, metric_readers=[reader]))
```
#### Custom Business Metrics
Flask auto-instrumentation already emits the standard HTTP server metrics,
including the `http.server.request.duration` histogram (its sample count gives
you request rate, latency percentiles, and error ratio per route), so there is
no need to hand-roll request latency. Reserve custom metrics for business
events the instrumentation cannot see, such as domain actions:
```python title="app/routes/articles.py" showLineNumbers
from opentelemetry import metrics
meter = metrics.get_meter("flask-app")
articles_created = meter.create_counter(
"articles.created",
unit="1",
description="Articles created",
)
# Inside the view, after the article is persisted:
articles_created.add(1, {"author_id": str(g.current_user.id)})
```
> View metrics in your base14 Scout dashboard to chart request rate, latency
> percentiles, and error ratio per route from the automatic HTTP histogram,
> alongside your custom business counters.
##### Reference
[Official Metrics Documentation](https://opentelemetry.io/docs/concepts/signals/metrics/)
### Production Configuration
Production deployments require optimized sampling, secure credential management,
and performance tuning for high-traffic Flask APIs.
#### Production Tracing Initialization
```python title="app/__init__.py" showLineNumbers
"""Production-optimized Flask tracing configuration."""
import os
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased, ParentBased
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
def initialize_production_tracing(app):
"""Initialize production-grade OpenTelemetry tracing."""
# Create resource with comprehensive metadata
resource = Resource.create({
"service.name": os.getenv("OTEL_SERVICE_NAME", "flask-order-service"),
"service.version": os.getenv("APP_VERSION", "1.0.0"),
"deployment.environment.name": os.getenv("ENVIRONMENT", "development"),
"environment": os.getenv("ENVIRONMENT", "development"),
"cloud.provider": os.getenv("CLOUD_PROVIDER", "aws"),
"cloud.region": os.getenv("AWS_REGION", "us-east-1"),
"k8s.cluster.name": os.getenv("K8S_CLUSTER", "production"),
"k8s.namespace.name": os.getenv("K8S_NAMESPACE", "default"),
"k8s.pod.name": os.getenv("HOSTNAME", "unknown"),
})
# Configure sampling (10% in production)
sample_rate = float(os.getenv("OTEL_TRACE_SAMPLE_RATE", "0.1"))
sampler = ParentBased(root=TraceIdRatioBased(sample_rate))
# Create tracer provider with sampler
provider = TracerProvider(resource=resource, sampler=sampler)
# Configure OTLP exporter with authentication
otlp_exporter = OTLPSpanExporter(
endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://scout.base14.io:4317"),
headers={
"authorization": f"Bearer {os.getenv('SCOUT_API_KEY', '')}",
},
insecure=False,
)
# Configure batch span processor with production settings
batch_processor = BatchSpanProcessor(
otlp_exporter,
max_queue_size=2048,
max_export_batch_size=512,
schedule_delay_millis=5000,
export_timeout_millis=30000,
)
provider.add_span_processor(batch_processor)
trace.set_tracer_provider(provider)
# Instrument Flask with excluded paths
FlaskInstrumentor().instrument_app(
app,
excluded_urls="health,readiness,metrics,favicon.ico"
)
# Instrument SQLAlchemy with query commenter
from app.extensions import db
SQLAlchemyInstrumentor().instrument(
engine=db.engine,
enable_commenter=True,
commenter_options={
"db_driver": True,
"db_framework": True,
}
)
# Instrument external HTTP calls
RequestsInstrumentor().instrument()
# Instrument Redis
RedisInstrumentor().instrument()
app.logger.info(
f"OpenTelemetry initialized: {resource.attributes.get('service.name')} "
f"v{resource.attributes.get('service.version')} (sample rate: {sample_rate})"
)
```
#### Gunicorn Production Configuration
```python title="gunicorn.conf.py" showLineNumbers
"""Gunicorn configuration for production Flask deployment."""
import multiprocessing
import os
# Server socket
bind = "0.0.0.0:5000"
backlog = 2048
# Worker processes
workers = int(os.getenv("GUNICORN_WORKERS", multiprocessing.cpu_count() * 2 + 1))
worker_class = "sync" # Or "gevent" for async workers
worker_connections = 1000
max_requests = 1000
max_requests_jitter = 50
timeout = 30
keepalive = 5
# Logging
accesslog = "-"
errorlog = "-"
loglevel = os.getenv("LOG_LEVEL", "info")
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
# Process naming
proc_name = "flask-order-service"
# Server hooks
def on_starting(server):
"""Server starting hook."""
print("Gunicorn server starting with OpenTelemetry tracing enabled")
def post_worker_init(worker):
"""Worker initialization hook."""
print(f"Worker {worker.pid} initialized")
```
#### Dockerfile (Multi-Stage Build)
```dockerfile title="Dockerfile" showLineNumbers
# Stage 1: Build dependencies
FROM python:3.13-slim AS builder
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
# Stage 2: Runtime image
FROM python:3.13-slim
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Copy wheels from builder
COPY --from=builder /app/wheels /wheels
COPY --from=builder /app/requirements.txt .
# Install Python packages
RUN pip install --no-cache /wheels/*
# Copy application code
COPY . .
# Create non-root user
RUN useradd -m -u 1000 flask && chown -R flask:flask /app
USER flask
# Environment variables
ENV PYTHONUNBUFFERED=1
ENV FLASK_APP=wsgi.py
# Expose port
EXPOSE 5000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:5000/health/')"
# Run Gunicorn
CMD ["gunicorn", "wsgi:app", "--config", "gunicorn.conf.py"]
```
#### Kubernetes Deployment
```yaml title="k8s/deployment.yaml" showLineNumbers
apiVersion: apps/v1
kind: Deployment
metadata:
name: flask-order-service
labels:
app: flask-order-service
spec:
replicas: 3
selector:
matchLabels:
app: flask-order-service
template:
metadata:
labels:
app: flask-order-service
spec:
containers:
- name: flask-app
image: flask-order-service:latest
ports:
- containerPort: 5000
name: http
env:
- name: FLASK_ENV
value: production
- name: OTEL_SERVICE_NAME
value: flask-order-service
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://scout-collector:4317
- name: SCOUT_API_KEY
valueFrom:
secretKeyRef:
name: scout-credentials
key: api-key
- name: OTEL_TRACE_SAMPLE_RATE
value: '0.1'
- name: ENVIRONMENT
value: demo
- name: APP_VERSION
value: '1.0.0'
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: postgres-credentials
key: connection-string
- name: REDIS_URL
value: redis://redis-service:6379/0
resources:
requests:
memory: '256Mi'
cpu: '250m'
limits:
memory: '512Mi'
cpu: '500m'
livenessProbe:
httpGet:
path: /health/
port: 5000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 5000
initialDelaySeconds: 10
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: flask-order-service
spec:
selector:
app: flask-order-service
ports:
- protocol: TCP
port: 80
targetPort: 5000
type: ClusterIP
```
### Flask-Specific Features
Flask's auto-instrumentation automatically captures routes, database queries,
and template rendering. The minimalist design allows fine-grained control over
what gets traced.
#### Route Auto-Instrumentation
All Flask routes are automatically instrumented:
```python title="app/routes/orders.py" showLineNumbers
"""Flask routes with automatic tracing."""
from flask import Blueprint, request, jsonify
from opentelemetry import trace
from app.models import Order, db
from app.tasks import process_order
orders_bp = Blueprint('orders', __name__)
tracer = trace.get_tracer(__name__)
# Function-based route (automatically traced)
# Span name: "GET /api/orders/"
@orders_bp.route('/', methods=['GET'])
def get_orders():
"""Get all orders - automatically creates span."""
orders = Order.query.all()
return jsonify([order.to_dict() for order in orders])
# Route with parameter (automatically traced)
# Span name: "GET /api/orders/"
@orders_bp.route('/', methods=['GET'])
def get_order(order_id):
"""Get order by ID."""
order = Order.query.get_or_404(order_id)
return jsonify(order.to_dict())
# POST route with request body
# Span name: "POST /api/orders/"
@orders_bp.route('/', methods=['POST'])
def create_order():
"""Create new order."""
data = request.get_json()
# Database insert automatically traced
order = Order(
product_name=data['product_name'],
amount=data['amount'],
status='pending'
)
db.session.add(order)
db.session.commit()
# Trigger background task (traced separately)
process_order.delay(order.id)
return jsonify(order.to_dict()), 201
# Error handling (automatically traced)
@orders_bp.route('/', methods=['DELETE'])
def delete_order(order_id):
"""Delete order."""
order = Order.query.get_or_404(order_id)
db.session.delete(order)
db.session.commit()
return '', 204
# Custom span with manual instrumentation
@orders_bp.route('/analytics', methods=['GET'])
def get_analytics():
"""Get order analytics with custom span."""
with tracer.start_as_current_span("calculate_analytics") as span:
total_orders = Order.query.count()
pending_orders = Order.query.filter_by(status='pending').count()
span.set_attribute("analytics.total_orders", total_orders)
span.set_attribute("analytics.pending_orders", pending_orders)
return jsonify({
"total_orders": total_orders,
"pending_orders": pending_orders
})
```
#### Blueprint-Based Architecture
Flask blueprints are automatically instrumented:
```python title="app/routes/health.py" showLineNumbers
"""Health check blueprint."""
from flask import Blueprint, jsonify
from app.extensions import db, redis_client
health_bp = Blueprint('health', __name__)
@health_bp.route('/', methods=['GET'])
def health():
"""Basic health check - excluded from tracing via config."""
return jsonify({"status": "healthy"})
@health_bp.route('/ready', methods=['GET'])
def readiness():
"""Readiness check with dependencies."""
try:
# Check database connection
db.session.execute('SELECT 1')
# Check Redis connection
redis_client.ping()
return jsonify({"status": "ready"})
except Exception as e:
return jsonify({"status": "not ready", "error": str(e)}), 503
```
#### SQLAlchemy Query Instrumentation
Database queries are automatically traced:
```python title="app/models.py" showLineNumbers
"""SQLAlchemy models with automatic query tracing."""
from datetime import datetime
from app.extensions import db
class Order(db.Model):
"""Order model - all queries automatically traced."""
__tablename__ = 'orders'
id = db.Column(db.Integer, primary_key=True)
product_name = db.Column(db.String(200), nullable=False)
amount = db.Column(db.Numeric(10, 2), nullable=False)
status = db.Column(db.String(50), default='pending')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
"""Convert to dictionary."""
return {
'id': self.id,
'product_name': self.product_name,
'amount': float(self.amount),
'status': self.status,
'created_at': self.created_at.isoformat(),
'updated_at': self.updated_at.isoformat()
}
@classmethod
def get_by_status(cls, status):
"""Get orders by status - traced as SELECT query."""
return cls.query.filter_by(status=status).all()
@classmethod
def get_recent(cls, limit=10):
"""Get recent orders - traced with ORDER BY and LIMIT."""
return cls.query.order_by(cls.created_at.desc()).limit(limit).all()
class OrderService:
"""Business logic with automatic query tracing."""
@staticmethod
def create_order_with_validation(data):
"""Create order with validation."""
# All database operations traced automatically
order = Order(
product_name=data['product_name'],
amount=data['amount'],
status='pending'
)
db.session.add(order)
db.session.commit()
return order
@staticmethod
def get_order_analytics():
"""Complex aggregation query - automatically traced."""
from sqlalchemy import func
result = db.session.query(
Order.status,
func.count(Order.id).label('count'),
func.sum(Order.amount).label('total')
).group_by(Order.status).all()
return [
{
'status': row.status,
'count': row.count,
'total': float(row.total or 0)
}
for row in result
]
```
#### Before/After Request Hooks
Flask request hooks are automatically traced:
```python title="app/__init__.py" showLineNumbers
"""Flask hooks with custom attributes."""
from flask import request
from opentelemetry import trace
def register_hooks(app):
"""Register Flask request hooks with tracing."""
@app.before_request
def before_request():
"""Before request hook - adds custom attributes."""
# Get current span and add custom attributes
current_span = trace.get_current_span()
current_span.set_attribute("user_agent.original", request.user_agent.string)
current_span.set_attribute("http.request.method", request.method)
current_span.set_attribute("url.path", request.path)
if request.is_json:
current_span.set_attribute("http.request.content_type", "application/json")
@app.after_request
def after_request(response):
"""After request hook - records response details."""
current_span = trace.get_current_span()
# Record response attributes
current_span.set_attribute("http.response.status_code", response.status_code)
current_span.set_attribute("http.response.body.size", response.content_length or 0)
return response
@app.errorhandler(Exception)
def handle_exception(e):
"""Global error handler - records exceptions in span."""
current_span = trace.get_current_span()
current_span.record_exception(e)
current_span.set_status(trace.Status(trace.StatusCode.ERROR, str(e)))
return jsonify({"error": str(e)}), 500
```
#### Celery Task Tracing
Celery tasks with distributed context propagation:
```python title="app/celery_app.py" showLineNumbers
"""Celery configuration with OpenTelemetry."""
import os
from celery import Celery
from celery.signals import worker_process_init
from opentelemetry.instrumentation.celery import CeleryInstrumentor
def make_celery(app):
"""Create Celery instance with Flask app context."""
celery = Celery(
app.import_name,
broker=app.config['CELERY_BROKER_URL'],
backend=app.config['CELERY_RESULT_BACKEND']
)
celery.conf.update(app.config)
# Ensure Flask app context in tasks
class ContextTask(celery.Task):
def __call__(self, *args, **kwargs):
with app.app_context():
return self.run(*args, **kwargs)
celery.Task = ContextTask
return celery
@worker_process_init.connect
def init_celery_tracing(**kwargs):
"""Initialize tracing in Celery worker process."""
from app import create_app
app = create_app('production')
# Instrument Celery tasks
CeleryInstrumentor().instrument()
print("OpenTelemetry initialized in Celery worker")
```
```python title="app/tasks.py" showLineNumbers
"""Celery tasks with automatic tracing."""
from app.celery_app import make_celery
from app import create_app
from opentelemetry import trace
app = create_app()
celery = make_celery(app)
tracer = trace.get_tracer(__name__)
@celery.task
def process_order(order_id):
"""
Process order asynchronously.
Automatically creates span: 'app.tasks.process_order'
"""
from app.models import Order, db
# Current span linked to original request
current_span = trace.get_current_span()
current_span.set_attribute("order.id", order_id)
# Query automatically traced
order = Order.query.get(order_id)
if order.amount > 1000:
# Custom validation span
with tracer.start_as_current_span("validate_high_value_order") as span:
span.set_attribute("order.amount", float(order.amount))
# Validation logic
pass
# Update status (UPDATE query traced)
order.status = 'processing'
db.session.commit()
return {"order_id": order_id, "status": "processed"}
```
### Custom Instrumentation
While Flask auto-instrumentation covers most use cases, custom spans are needed
for specific business logic.
#### Manual Span Creation
```python title="app/services/payment.py" showLineNumbers
"""Payment service with custom instrumentation."""
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
import requests
tracer = trace.get_tracer(__name__)
class PaymentService:
"""Payment processing with custom spans."""
@staticmethod
def process_payment(order_id, amount, method):
"""Process payment with detailed tracing."""
with tracer.start_as_current_span("process_payment") as span:
span.set_attribute("order.id", order_id)
span.set_attribute("payment.amount", float(amount))
span.set_attribute("payment.method", method)
try:
# Validate payment method
PaymentService._validate_method(method)
# Call external gateway
transaction_id = PaymentService._charge_gateway(amount, method)
span.set_attribute("payment.transaction_id", transaction_id)
span.set_status(Status(StatusCode.OK))
return {
"success": True,
"transaction_id": transaction_id
}
except Exception as e:
span.set_status(Status(StatusCode.ERROR, str(e)))
span.record_exception(e)
raise
@staticmethod
def _validate_method(method):
"""Validate payment method."""
with tracer.start_as_current_span("validate_payment_method") as span:
span.set_attribute("payment.method", method)
valid_methods = ["credit_card", "debit_card", "paypal"]
if method not in valid_methods:
span.set_status(Status(StatusCode.ERROR, "Invalid method"))
raise ValueError(f"Invalid payment method: {method}")
@staticmethod
def _charge_gateway(amount, method):
"""Charge payment gateway - external API call."""
with tracer.start_as_current_span(
"payment_gateway.charge",
kind=trace.SpanKind.CLIENT
) as span:
span.set_attribute("payment.gateway", "stripe")
span.set_attribute("payment.amount", float(amount))
# External HTTP call (auto-instrumented by requests library)
response = requests.post(
"https://api.stripe.com/v1/charges",
json={"amount": float(amount), "method": method},
timeout=10
)
span.set_attribute("http.response.status_code", response.status_code)
if response.status_code == 200:
transaction_id = response.json().get("id")
span.set_attribute("payment.transaction_id", transaction_id)
return transaction_id
else:
span.set_status(Status(StatusCode.ERROR, "Payment failed"))
raise Exception("Payment gateway error")
```
#### Context Manager for Business Logic
```python title="app/utils/tracing.py" showLineNumbers
"""Custom tracing utilities."""
from opentelemetry import trace
from functools import wraps
import time
tracer = trace.get_tracer(__name__)
def trace_function(name=None):
"""Decorator to automatically trace function execution."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
span_name = name or f"{func.__module__}.{func.__name__}"
with tracer.start_as_current_span(span_name) as span:
# Add function metadata
span.set_attribute("function.name", func.__name__)
span.set_attribute("function.module", func.__module__)
# Record arguments (be careful with PII!)
if kwargs:
span.set_attribute("function.kwargs_count", len(kwargs))
start_time = time.time()
try:
result = func(*args, **kwargs)
duration_ms = (time.time() - start_time) * 1000
span.set_attribute("function.duration_ms", duration_ms)
return result
except Exception as e:
span.record_exception(e)
span.set_status(trace.Status(trace.StatusCode.ERROR, str(e)))
raise
return wrapper
return decorator
# Usage
@trace_function("calculate_order_total")
def calculate_total(order_items):
"""Calculate order total - automatically traced."""
return sum(item['price'] * item['quantity'] for item in order_items)
```
#### Flask Extension Instrumentation
```python title="app/extensions.py" showLineNumbers
"""Flask extensions with tracing integration."""
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_redis import FlaskRedis
from opentelemetry import trace
db = SQLAlchemy()
migrate = Migrate()
redis_client = FlaskRedis()
tracer = trace.get_tracer(__name__)
def init_extensions(app):
"""Initialize Flask extensions with tracing."""
db.init_app(app)
migrate.init_app(app, db)
redis_client.init_app(app)
# Add custom event listeners for database operations
@event.listens_for(db.engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
"""Log SQL queries in current span."""
current_span = trace.get_current_span()
current_span.add_event("sql_query_start", {
"db.query.text": statement[:100], # Truncate long queries
})
app.logger.info("Extensions initialized with OpenTelemetry tracing")
```
### Running Your Application
Flask applications can run with various WSGI servers. OpenTelemetry
instrumentation works with all standard deployment methods.
#### Development Server
```bash title="Terminal" showLineNumbers
# Run Flask development server
export FLASK_APP=wsgi.py
export FLASK_ENV=development
flask run --host=0.0.0.0 --port=5000
# Or using Python directly
python wsgi.py
# Run Celery worker (separate terminal)
celery -A app.celery_app worker --loglevel=info
# Test endpoints
curl http://localhost:5000/api/orders/
curl -X POST http://localhost:5000/api/orders/ \
-H "Content-Type: application/json" \
-d '{"product_name":"Widget","amount":"99.99"}'
```
#### Gunicorn (Production)
```bash title="Terminal" showLineNumbers
# Run with Gunicorn
gunicorn wsgi:app --bind 0.0.0.0:5000 --workers 4
# With configuration file
gunicorn wsgi:app --config gunicorn.conf.py
# With environment variables
OTEL_SERVICE_NAME=flask-order-service \
OTEL_EXPORTER_OTLP_ENDPOINT=https://scout.base14.io:4317 \
SCOUT_API_KEY=your_api_key \
gunicorn wsgi:app --bind 0.0.0.0:5000
```
#### uWSGI
```ini title="uwsgi.ini" showLineNumbers
[uwsgi]
module = wsgi:app
master = true
processes = 4
socket = /tmp/flask-app.sock
chmod-socket = 666
vacuum = true
die-on-term = true
# OpenTelemetry environment
env = OTEL_SERVICE_NAME=flask-order-service
env = OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
```
Run:
```bash
uwsgi --ini uwsgi.ini
```
#### Docker Deployment
```bash title="Terminal" showLineNumbers
# Build Docker image
docker build -t flask-order-service:latest .
# Run container
docker run -p 5000:5000 \
-e OTEL_SERVICE_NAME=flask-order-service \
-e OTEL_EXPORTER_OTLP_ENDPOINT=https://scout.base14.io:4317 \
-e SCOUT_API_KEY=your_api_key \
flask-order-service:latest
# Run with Docker Compose
docker-compose up -d
# Check logs
docker-compose logs -f flask-app
# Run database migrations
docker-compose exec flask-app flask db upgrade
```
#### Kubernetes Deployment
```bash title="Terminal" showLineNumbers
# Deploy to Kubernetes
kubectl apply -f k8s/deployment.yaml
# Check pod status
kubectl get pods -l app=flask-order-service
# View logs
kubectl logs -f deployment/flask-order-service
# Run migrations
kubectl run flask-migrate --rm -i --tty \
--image=flask-order-service:latest \
--restart=Never \
--command -- flask db upgrade
# Port forward for testing
kubectl port-forward deployment/flask-order-service 5000:5000
curl http://localhost:5000/api/orders/
```
### Troubleshooting
#### Issue 1: No Traces Generated
**Symptoms:** Flask application starts but no traces appear in collector.
**Solution:**
Ensure instrumentation is called on the Flask app instance:
```python
# INCORRECT: Instrumenting before app creation
from opentelemetry.instrumentation.flask import FlaskInstrumentor
FlaskInstrumentor().instrument() # No app provided!
from flask import Flask
app = Flask(__name__)
# CORRECT: Instrument specific app instance
from flask import Flask
app = Flask(__name__)
from opentelemetry.instrumentation.flask import FlaskInstrumentor
FlaskInstrumentor().instrument_app(app) # Instrument this specific app
```
#### Issue 2: SQLAlchemy Queries Not Traced
**Symptoms:** HTTP requests create spans but database queries are missing.
**Solution:**
Instrument SQLAlchemy engine after database initialization:
```python
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
# Initialize database
from app.extensions import db
db.init_app(app)
# IMPORTANT: Instrument engine after init_app
SQLAlchemyInstrumentor().instrument(
engine=db.engine,
enable_commenter=True
)
```
#### Issue 3: Blueprint Routes Not Traced
**Symptoms:** Some routes traced, blueprint routes missing.
**Solution:**
Register blueprints before instrumenting Flask app:
```python
# INCORRECT order
FlaskInstrumentor().instrument_app(app)
app.register_blueprint(orders_bp) # Too late!
# CORRECT order
app.register_blueprint(orders_bp)
app.register_blueprint(health_bp)
FlaskInstrumentor().instrument_app(app) # Instrument after blueprints
```
#### Issue 4: Celery Tasks Not Linked to Requests
**Symptoms:** Celery task spans exist but disconnected from originating request.
**Solution:**
Initialize Celery instrumentation in worker process:
```python
from celery.signals import worker_process_init
from opentelemetry.instrumentation.celery import CeleryInstrumentor
@worker_process_init.connect
def init_celery_tracing(**kwargs):
"""Initialize in each worker process."""
# Initialize OpenTelemetry
from app import initialize_tracing
app = create_app()
initialize_tracing(app)
# Instrument Celery
CeleryInstrumentor().instrument()
```
#### Issue 5: High Memory Usage
**Symptoms:** Flask application memory grows continuously.
**Solution:**
Configure batch span processor limits:
```python
from opentelemetry.sdk.trace.export import BatchSpanProcessor
batch_processor = BatchSpanProcessor(
otlp_exporter,
max_queue_size=1024, # Reduced from default
max_export_batch_size=256,
schedule_delay_millis=3000,
)
```
### Security Considerations
#### PII Data Masking
Implement custom span processor to mask sensitive data:
```python title="app/tracing.py" showLineNumbers
"""PII masking span processor."""
import re
from opentelemetry.sdk.trace import SpanProcessor, ReadableSpan
class PIIMaskingProcessor(SpanProcessor):
"""Mask PII in span attributes."""
EMAIL_PATTERN = re.compile(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}')
PHONE_PATTERN = re.compile(r'\b\d{3}[-.]?\d{3}[-.]?\d{4}\b')
CREDIT_CARD_PATTERN = re.compile(r'\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b')
def on_start(self, span: "ReadableSpan", parent_context=None):
"""Mask PII on span start."""
if hasattr(span, '_name'):
span._name = self._mask(span._name)
def on_end(self, span: ReadableSpan):
"""Mask PII on span end."""
if hasattr(span, 'attributes'):
for key, value in list(span.attributes.items()):
if isinstance(value, str):
span.attributes[key] = self._mask(value)
def _mask(self, text):
"""Mask sensitive patterns."""
if not isinstance(text, str):
return text
text = self.EMAIL_PATTERN.sub('***@***.**', text)
text = self.PHONE_PATTERN.sub('***-***-****', text)
text = self.CREDIT_CARD_PATTERN.sub('****-****-****-****', text)
return text
def shutdown(self):
pass
def force_flush(self, timeout_millis=30000):
return True
```
#### SQL Parameter Sanitization
```python
# Enable SQL commenter without parameter values
SQLAlchemyInstrumentor().instrument(
engine=db.engine,
enable_commenter=True,
commenter_options={
"db_driver": True,
"opentelemetry_values": False, # Don't log parameters
}
)
```
#### Request Header Filtering
```python title="app/__init__.py" showLineNumbers
"""Filter sensitive headers."""
@app.before_request
def filter_sensitive_headers():
"""Remove sensitive headers from tracing."""
current_span = trace.get_current_span()
# Don't trace authorization headers
# current_span.set_attribute("http.authorization", request.headers.get("Authorization"))
# Only log that auth is present
if request.headers.get("Authorization"):
current_span.set_attribute("http.auth.present", True)
```
### Performance Considerations
#### Tracing Overhead Metrics
| Configuration | Latency (p50) | Latency (p99) | Throughput | Memory |
| ------------------ | ------------- | ------------- | ---------- | ------- |
| **No Tracing** | 8ms | 25ms | 12,000 rps | 180MB |
| **Tracing (100%)** | 9ms (+12%) | 30ms (+20%) | 11,000 rps | 250MB |
| **Tracing (10%)** | 8ms (+<1%) | 26ms (+4%) | 11,800 rps | 195MB |
#### Optimization Strategies
##### 1. Exclude High-Volume Endpoints
```python
FlaskInstrumentor().instrument_app(
app,
excluded_urls="health,readiness,metrics,static,favicon.ico"
)
```
##### 2. Optimize Database Queries
```python
# BAD: N+1 query problem
orders = Order.query.all()
for order in orders:
print(order.user.username) # N queries!
# GOOD: Eager loading
from sqlalchemy.orm import joinedload
orders = Order.query.options(joinedload(Order.user)).all()
for order in orders:
print(order.user.username) # Single query
```
##### 3. Batch Span Export
```python
batch_processor = BatchSpanProcessor(
otlp_exporter,
max_queue_size=2048,
max_export_batch_size=512,
schedule_delay_millis=5000,
)
```
##### 4. Disable Tracing in Tests
```python
# config.py
class TestingConfig(Config):
TESTING = True
OTEL_SDK_DISABLED = True
```
### FAQ
#### Do I need to manually instrument Flask routes with OpenTelemetry?
**No.** Flask's auto-instrumentation automatically traces all routes when you
call `FlaskInstrumentor().instrument_app(app)`. No decorators required.
#### How do I trace Flask blueprints?
Blueprints are automatically instrumented when registered before calling
`instrument_app()`. Ensure blueprints are registered first, then instrument.
#### Can I use OpenTelemetry with Flask-RESTful?
**Yes.** Flask-RESTful resources are automatically instrumented through Flask's
route system. Each resource method creates a span.
#### How do I trace SQLAlchemy queries in a Flask app with OpenTelemetry?
Install `opentelemetry-instrumentation-sqlalchemy` and instrument the engine
after `db.init_app(app)`:
```python
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
SQLAlchemyInstrumentor().instrument(engine=db.engine)
```
#### Does OpenTelemetry tracing work with the Flask application factory?
**Yes.** Initialize tracing inside your `create_app()` function after creating
the app instance but before returning it.
#### How do I trace Celery tasks from Flask?
Install `opentelemetry-instrumentation-celery` and initialize in the worker
process using `@worker_process_init` signal. Trace context automatically
propagates from Flask to Celery.
#### Can I exclude specific Flask routes from OpenTelemetry tracing?
**Yes.** Use the `excluded_urls` parameter:
```python
FlaskInstrumentor().instrument_app(app, excluded_urls="health,metrics")
```
#### What is the performance overhead of OpenTelemetry tracing in Flask?
With 10% sampling, overhead is typically <1% for latency and ~8% for memory.
Without sampling (100% tracing), expect ~12% latency increase.
#### How do I send traces to Base14 Scout?
Point the OTLP exporter at your Scout endpoint on port 4317 and pass your
API key as a bearer token in the headers:
```python
OTLPSpanExporter(
endpoint="https://scout.base14.io:4317",
headers={"authorization": f"Bearer {os.getenv('SCOUT_API_KEY')}"},
)
```
#### Can I trace template rendering?
**Yes.** Jinja2 template rendering is automatically traced when using
`render_template()`. Each template creates a child span.
#### How do I trace before/after request hooks?
Flask hooks are automatically traced. Add custom attributes in hooks using
`trace.get_current_span()`.
#### Does tracing work with gevent workers?
**Yes.** OpenTelemetry works with gevent and eventlet WSGI workers. Context
propagation is maintained across greenlets.
### What's Next
Now that you have Flask instrumented with OpenTelemetry, explore advanced
observability patterns:
#### Advanced Tracing Topics
- **[Custom Instrumentation for Python](/instrument/apps/custom-instrumentation/python)**
\- Deep dive into manual span creation and context propagation
- **[All framework guides](/instrument/apps/auto-instrumentation/)** \-
Auto-instrumentation overview for every language
#### Scout Platform Features
- **[Base14 Scout Dashboard](https://base14.io/scout)** - Visualize Flask
traces with route-level insights
- **Service Map Visualization** - Understand dependencies between Flask
microservices
- **Alert Configuration** - Set up alerts for slow routes and high error rates
#### Deployment & Operations
- **Docker Instrumentation** - Deploy instrumented Flask apps in containers
- **Kubernetes Deployment** - Run Flask with sidecar collectors
- **AWS Lambda** - Deploy Flask with AWS Lambda + API Gateway tracing
### Complete Example
Here's a complete Flask application with OpenTelemetry instrumentation,
including routes, SQLAlchemy, blueprints, and Celery tasks.
#### Project Structure
```text
flask-order-service/
├── app/
│ ├── __init__.py
│ ├── extensions.py
│ ├── models.py
│ ├── celery_app.py
│ ├── tasks.py
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── orders.py
│ │ └── health.py
│ └── services/
│ └── payment.py
├── config.py
├── wsgi.py
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
└── gunicorn.conf.py
```
#### Running the Example
```bash title="Terminal" showLineNumbers
# Clone the examples repository
git clone https://github.com/base-14/examples.git
cd examples/python/flask-postgres
# Create virtual environment
python -m venv venv
source venv/bin/activate
# Install dependencies
pip install -r requirements.txt
# Start infrastructure
docker-compose up -d postgres redis scout-collector
# Initialize database
export FLASK_APP=wsgi.py
flask db upgrade
# Run development server
flask run
# In another terminal: Run Celery worker
celery -A app.celery_app worker --loglevel=info
# Test endpoints
curl http://localhost:5000/api/orders/
curl -X POST http://localhost:5000/api/orders/ \
-H "Content-Type: application/json" \
-d '{"product_name":"Widget","amount":"99.99"}'
# View traces in Scout
open https://scout.base14.io
```
#### Expected Trace Output
```text
POST /api/orders/ (150ms)
├── create_order (120ms)
│ ├── INSERT INTO orders (...) (15ms)
│ ├── celery.apply_async (5ms)
│ └── db.session.commit (10ms)
└── Celery Task: process_order (100ms) [linked trace]
├── SELECT FROM orders WHERE id = ? (8ms)
├── validate_high_value_order (30ms)
└── UPDATE orders SET status = ? (10ms)
```
:::tip Complete Example Repository
The full example application with Docker Compose, Kubernetes manifests, and
production configurations is available at:
**[https://github.com/base-14/examples/tree/main/python/flask-postgres](https://github.com/base-14/examples/tree/main/python/flask-postgres)**
:::
### References
#### Official Documentation
- **[Flask Documentation](https://flask.palletsprojects.com/)** \- Official
Flask framework documentation
- **[OpenTelemetry Python SDK](https://opentelemetry.io/docs/languages/python/)**
\- Core OpenTelemetry Python documentation
- **[Flask Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/flask/flask.html)**
\- Official Flask auto-instrumentation docs
- **[SQLAlchemy Documentation](https://www.sqlalchemy.org/)** \- ORM and Core
documentation
#### Tools & Resources
- **[Base14 Scout](https://base14.io/scout)** \- Managed OpenTelemetry platform
for Flask microservices
- **[Flask-RESTful](https://flask-restful.readthedocs.io/)** \- REST API
extension for Flask
- **[Flask-SQLAlchemy](https://flask-sqlalchemy.palletsprojects.com/)** \- Flask
extension for SQLAlchemy
### Related Guides
- [Django Instrumentation](/instrument/apps/auto-instrumentation/django) -
Full-featured Python framework with ORM
- [FastAPI Instrumentation](/instrument/apps/auto-instrumentation/fast-api) -
Modern async Python framework
- [Celery Tracing](/instrument/apps/auto-instrumentation/celery) - Distributed
task queue instrumentation
- [Python Custom Instrumentation](/instrument/apps/custom-instrumentation/python)
\- Manual spans and advanced patterns
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) - Set
up the collector for local development
---
## Go stdlib OpenTelemetry Instrumentation - net/http + pgx
## Go stdlib + Postgres
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Introduction
This guide instruments a Go service that uses only the **standard library
`net/http`**, the `pgx/v5` Postgres driver, and the OpenTelemetry Go SDK -
no Echo, Fiber, Chi, or Gin involved. Go 1.22 added pattern-based routing
(`GET /api/articles/{id}`) directly to `http.ServeMux`, which removed the
main reason most teams pulled in a third-party router. The result is a
production service with full server, client, and database tracing and zero
framework dependency.
This guide builds on the [Go instrumentation guide](./go.md) with a complete
standard-library and PostgreSQL example.
Three OpenTelemetry contrib packages do most of the work. `otelhttp` wraps
the mux to emit server spans on every inbound request and instruments
outbound `http.Client` calls so W3C `traceparent` headers propagate
automatically across services. `otelpgx` plugs into the pgx pool config to
emit `pool.acquire`, `prepare`, and `query` spans on every database call.
`otelslog` bridges Go's `log/slog` into the OTel logs pipeline so structured
JSON logs flow to the same collector as your traces and metrics.
If you already export traces to Datadog, New Relic, or Honeycomb, the same
SDK setup works for Base14 Scout - swap the OTLP endpoint, wire up the
collector's `oauth2client` extension for Scout's bearer token, and the rest
of the code is identical. This guide covers prerequisites, installation,
configuration, production hardening, custom instrumentation, troubleshooting,
security, performance, and a complete worked example based on
[go/stdlib-postgres](https://github.com/base-14/examples/tree/main/go/stdlib-postgres).
:::tip TL;DR
Wrap `http.ServeMux` with `otelhttp.NewHandler`, set
`cfg.ConnConfig.Tracer = otelpgx.NewTracer()` on the pgxpool config, and
install `propagation.TraceContext` as the global propagator. Initialize a
`TracerProvider`, `MeterProvider`, and `LoggerProvider` once in `main`,
defer their `Shutdown` calls, and point `OTEL_EXPORTER_OTLP_ENDPOINT` at
your collector. You now have HTTP, database, and log telemetry without any
framework dependency.
:::
### Who This Guide Is For
This documentation is designed for:
- **Go developers** building services on `net/http` 1.22+ who want full
HTTP, database, and log telemetry without adopting Echo, Fiber, or Chi.
- **Backend engineers** running Go services on Postgres via `pgx`, including
teams migrating from `database/sql` + ORMs to native pgx for connection
pooling and `LISTEN`/`NOTIFY`.
- **Platform engineers** standardizing OTel across mixed-framework Go
fleets where the lowest common denominator is the standard library.
- **DevOps and SRE teams** deploying distroless Go binaries to Kubernetes
and needing trace/log correlation that survives without shells, package
managers, or runtime dependencies in the container.
- **Developers migrating from Datadog, New Relic, or Dynatrace** APM
agents to vendor-neutral OpenTelemetry on Base14 Scout.
### Overview
#### Prerequisites
Before starting, ensure you have:
- **Go 1.22 or later** for `ServeMux` pattern routing (`GET /api/x/{id}`).
Go 1.26+ recommended for the latest runtime metrics integration.
- **PostgreSQL 14 or later**. The example uses Postgres 18.
- **Docker and Docker Compose v2** for local multi-service testing.
- **OpenTelemetry Collector** (Contrib distribution) running locally or
remotely. The example bundles `otel/opentelemetry-collector-contrib:0.149.0`.
- **Base14 Scout credentials** (`SCOUT_ENDPOINT`, `SCOUT_CLIENT_ID`,
`SCOUT_CLIENT_SECRET`, `SCOUT_TOKEN_URL`) if you want to forward telemetry
to Scout. Skip these for local-only development.
#### Compatibility Matrix
| Component | Version | Notes |
| -------------------------- | ----------------- | -------------------------------------------------- |
| Go | 1.22+ | 1.22 required for mux pattern routing |
| Go (recommended) | 1.26 | Used by the reference example |
| pgx | v5.9+ | `pgxpool` for connection pooling |
| PostgreSQL | 14, 15, 16, 17, 18 | Tested on 18 |
| otelhttp | v0.68+ | Server handler + client transport |
| otelpgx | v0.10+ | pgx tracer plugin |
| otelslog bridge | v0.18+ | `log/slog` → OTel logs pipeline |
| OTel Go SDK | v1.43+ | `go.opentelemetry.io/otel` |
| OTel logs SDK | v0.19+ | `sdk/log`, `exporters/otlp/otlplog/otlploghttp` |
| OpenTelemetry Collector | 0.149+ | Contrib build (oauth2client extension) |
| Distroless base image | static-debian12 | `gcr.io/distroless/static-debian12:nonroot` |
#### Instrumented Components
| Component | What's Captured |
| ----------------------- | --------------------------------------------------------------------- |
| `http.ServeMux` (server)| Server span per request, route pattern, status code, latency |
| `http.Client` (outbound)| Client span per request, traceparent injection, propagated context |
| pgx connection pool | `pool.acquire`, `pool.connect`, `prepare`, `query` spans |
| Postgres queries | SQL statement (parameterized), rows affected, duration |
| `log/slog` | OTLP log records with trace_id/span_id, severity, body, attributes |
| Outbound traceparent | W3C `traceparent` header on every otelhttp-instrumented request |
| Custom metric | `articles.created` Int64Counter, exported every 60 s |
| Process resource attrs | `process.runtime.name=go`, `process.pid`, telemetry SDK info |
The complete reference application lives in
[go/stdlib-postgres](https://github.com/base-14/examples/tree/main/go/stdlib-postgres)
and ships an articles API on port 8080 plus a notify service on port 8081.
Both export OTLP/HTTP to a collector, which forwards to Scout over TLS with
an OAuth2 client-credentials flow.
### Installation
The Go SDK is a set of Go modules - there is no agent or pre-shipped binary.
Add them to `go.mod` and the rest is library code.
```mdx-code-block
```
```bash
go get go.opentelemetry.io/otel \
go.opentelemetry.io/otel/sdk \
go.opentelemetry.io/otel/sdk/metric \
go.opentelemetry.io/otel/sdk/log \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp \
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp \
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp \
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp \
go.opentelemetry.io/contrib/bridges/otelslog \
github.com/exaring/otelpgx \
github.com/jackc/pgx/v5
```
```mdx-code-block
```
```go title="app/go.mod" showLineNumbers
module stdlib-articles
go 1.26.1
require (
github.com/exaring/otelpgx v0.10.0
github.com/jackc/pgx/v5 v5.9.2
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/log v0.19.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/log v0.19.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
)
```
After updating `go.mod`, run `go mod tidy && go mod download`.
```mdx-code-block
```
```dockerfile title="app/Dockerfile" showLineNumbers
FROM golang:1.26-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app .
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /out/app /app/app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/app"]
```
`CGO_ENABLED=0` produces a static binary that runs on the distroless
`static-debian12:nonroot` image - no glibc, no shell, no package manager.
The OTel Go SDK is pure Go, so no extra packages or runtime dependencies
are needed.
```mdx-code-block
```
### Configuration
The SDK reads OTLP endpoint, service name, and resource attributes from
environment variables. Three places they typically come from: shell exports
for local runs, Docker Compose for end-to-end testing, and an explicit
`resource.New` call inside `initTelemetry` for code-controlled defaults.
```mdx-code-block
```
```bash title=".env.example" showLineNumbers
# Local development
APP_PORT=8080
DATABASE_URL=postgres://postgres:postgres@db:5432/stdlib_articles?sslmode=disable
NOTIFY_URL=http://notify:8081/notify
# OpenTelemetry SDK
OTEL_SERVICE_NAME=stdlib-articles
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development,environment=development,service.namespace=examples
# Scout (only needed in the collector environment)
SCOUT_ENDPOINT=https://your-scout-endpoint
SCOUT_CLIENT_ID=your-client-id
SCOUT_CLIENT_SECRET=your-client-secret
SCOUT_TOKEN_URL=https://your-token-url
SCOUT_ENVIRONMENT=development
```
`OTEL_SERVICE_NAME` lands in the `service.name` resource attribute, which
Scout uses to group spans, logs, and metrics into a single service view.
`OTEL_RESOURCE_ATTRIBUTES` accepts comma-separated `key=value` pairs and
augments whatever you set in code.
```mdx-code-block
```
```yaml title="compose.yml" showLineNumbers
services:
app:
build: ./app
ports:
- "8080:8080"
environment:
APP_PORT: "8080"
DATABASE_URL: postgres://postgres:postgres@db:5432/stdlib_articles?sslmode=disable
NOTIFY_URL: http://notify:8081/notify
OTEL_SERVICE_NAME: stdlib-articles
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_RESOURCE_ATTRIBUTES: deployment.environment=${SCOUT_ENVIRONMENT:-development},environment=${SCOUT_ENVIRONMENT:-development},service.namespace=examples
depends_on:
db:
condition: service_healthy
otel-collector:
condition: service_started
notify:
build: ./notify
ports:
- "8081:8081"
environment:
NOTIFY_PORT: "8081"
OTEL_SERVICE_NAME: stdlib-notify
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_RESOURCE_ATTRIBUTES: deployment.environment=${SCOUT_ENVIRONMENT:-development},environment=${SCOUT_ENVIRONMENT:-development},service.namespace=examples
depends_on:
otel-collector:
condition: service_started
```
The Compose file pins `OTEL_EXPORTER_OTLP_ENDPOINT` to the Docker DNS name
of the collector service. No host networking, no `host.docker.internal`
games, and no host-side OTLP listener required.
```mdx-code-block
```
```go title="app/telemetry.go" showLineNumbers
package main
import (
"context"
"fmt"
"strings"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation"
sdklog "go.opentelemetry.io/otel/sdk/log"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
type shutdownFunc func(context.Context) error
func initTelemetry(ctx context.Context, serviceName, endpoint string) (shutdownFunc, error) {
res, err := resource.New(ctx,
resource.WithFromEnv(),
resource.WithProcess(),
resource.WithTelemetrySDK(),
resource.WithAttributes(
semconv.ServiceName(serviceName),
semconv.ServiceVersion("1.0.0"),
),
)
if err != nil {
return nil, fmt.Errorf("resource: %w", err)
}
traceExp, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint(stripScheme(endpoint)),
otlptracehttp.WithInsecure(),
)
if err != nil {
return nil, fmt.Errorf("trace exporter: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExp),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.AlwaysSample()),
)
metricExp, err := otlpmetrichttp.New(ctx,
otlpmetrichttp.WithEndpoint(stripScheme(endpoint)),
otlpmetrichttp.WithInsecure(),
)
if err != nil {
return nil, fmt.Errorf("metric exporter: %w", err)
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp,
sdkmetric.WithInterval(60*time.Second))),
sdkmetric.WithResource(res),
)
logExp, err := otlploghttp.New(ctx,
otlploghttp.WithEndpoint(stripScheme(endpoint)),
otlploghttp.WithInsecure(),
)
if err != nil {
return nil, fmt.Errorf("log exporter: %w", err)
}
lp := sdklog.NewLoggerProvider(
sdklog.WithResource(res),
sdklog.WithProcessor(sdklog.NewBatchProcessor(logExp)),
)
otel.SetTracerProvider(tp)
otel.SetMeterProvider(mp)
global.SetLoggerProvider(lp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
return func(ctx context.Context) error {
var errs []error
if err := tp.Shutdown(ctx); err != nil {
errs = append(errs, err)
}
if err := mp.Shutdown(ctx); err != nil {
errs = append(errs, err)
}
if err := lp.Shutdown(ctx); err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
return fmt.Errorf("shutdown: %v", errs)
}
return nil
}, nil
}
func stripScheme(endpoint string) string {
if s := strings.TrimPrefix(endpoint, "https://"); s != endpoint {
return s
}
return strings.TrimPrefix(endpoint, "http://")
}
```
`resource.WithFromEnv()` merges anything in `OTEL_RESOURCE_ATTRIBUTES`,
`WithProcess()` adds `process.pid`, `process.runtime.name=go`, and
`process.runtime.version`, and `WithTelemetrySDK()` records the SDK
version. The composite propagator handles W3C `traceparent` and `baggage`.
```mdx-code-block
```
### Production Configuration
#### Batch and exporter tuning
The defaults work for most services. The two knobs you usually touch:
- **Trace batch size and timeout** - `sdktrace.WithBatcher(exp,
sdktrace.WithMaxExportBatchSize(512),
sdktrace.WithBatchTimeout(5*time.Second))` for high-throughput services.
- **Metric export interval** - the example uses 60 s
(`sdkmetric.WithInterval(60*time.Second)`). Drop to 15-30 s for tighter
alerting; raise to 120 s for low-traffic batch jobs.
#### GZIP compression on the OTLP exporter
Enable GZIP at the exporter layer for the leg between collector and Scout:
```yaml title="config/otel-config.yaml" showLineNumbers
exporters:
otlp_http/scout:
endpoint: ${SCOUT_ENDPOINT}
auth:
authenticator: oauth2client
tls:
insecure_skip_verify: true
compression: gzip
timeout: 30s
retry_on_failure:
enabled: true
initial_interval: 1s
max_interval: 30s
max_elapsed_time: 300s
```
The Go SDK exporter (`otlptracehttp`, `otlpmetrichttp`, `otlploghttp`)
defaults to `gzip` compression on the wire from the app to the collector;
no extra setup needed there.
#### Distroless multi-stage Dockerfile
The example builds a static binary on `golang:1.26-alpine` and ships it on
`gcr.io/distroless/static-debian12:nonroot`. The runtime image has no
shell, no package manager, and a non-root UID by default:
```dockerfile title="app/Dockerfile" showLineNumbers
FROM golang:1.26-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app .
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /out/app /app/app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/app"]
```
`-ldflags="-s -w"` strips DWARF and symbol tables, cutting the binary to
~15 MB. Distroless `static-debian12` adds about 2 MB on top of that, so the
final image is in the 17-20 MB range.
#### Multi-service distributed tracing
The example wires two services - `stdlib-articles` (port 8080) and
`stdlib-notify` (port 8081). The articles service calls notify with a
`http.Client` whose transport is `otelhttp.NewTransport(http.DefaultTransport)`.
That transport injects W3C `traceparent` on the outbound request. The notify
service wraps its mux with `otelhttp.NewHandler`, which extracts the same
header into a child span. Both traces share one `trace_id`:
```text
http.server (stdlib-articles) POST /api/articles
├── prepare (otelpgx)
├── query (otelpgx) INSERT INTO articles ...
└── HTTP POST (otelhttp.client)
└── http.server (stdlib-notify) POST /notify
```
This works without any code in the application beyond the otelhttp
wrappers and a global `propagation.TraceContext` propagator.
### Framework-Specific Features
#### Server-side: otelhttp.NewHandler around ServeMux
```go title="app/main.go" showLineNumbers
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", handler.Health)
articles.Register(mux)
server := &http.Server{
Addr: ":" + port,
Handler: otelhttp.NewHandler(mux, "http.server",
otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string {
return r.Method + " " + r.URL.Path
}),
),
ReadHeaderTimeout: 5 * time.Second,
}
```
`otelhttp.NewHandler` produces one server span per request with attributes
for HTTP method, route, status code, and duration. The
`WithSpanNameFormatter` callback overrides the default `"HTTP {method}"`
with the more searchable `"GET /api/articles/{id}"` shape.
#### Client-side: otelhttp.NewTransport
```go title="app/service/notification.go" showLineNumbers
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"stdlib-articles/model"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
type Notifier struct {
url string
client *http.Client
}
func NewNotifier(url string) *Notifier {
return &Notifier{
url: url,
client: &http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport),
Timeout: 5 * time.Second,
},
}
}
func (n *Notifier) NotifyArticleCreated(ctx context.Context, article *model.Article) error {
if n.url == "" {
return nil
}
payload := map[string]any{
"event": "article.created",
"article_id": article.ID,
"title": article.Title,
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := n.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("notify returned status %d", resp.StatusCode)
}
return nil
}
```
The key call is `http.NewRequestWithContext(ctx, ...)`. Without that, the
transport has no parent span context to propagate. Forget it and you'll
see two disconnected traces in Scout instead of one.
#### pgx tracer with otelpgx
```go title="app/main.go" showLineNumbers
func newPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, err
}
cfg.ConnConfig.Tracer = otelpgx.NewTracer()
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, err
}
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, err
}
return pool, nil
}
```
`otelpgx.NewTracer()` plugs into pgx's `Tracer` interface and emits four
span kinds:
| Span name | When |
| ---------------- | ------------------------------------------------- |
| `pool.connect` | New connection added to the pool |
| `pool.acquire` | Goroutine checks out a connection |
| `prepare` | Statement preparation |
| `query` | `Exec`, `Query`, or `QueryRow` |
Spans carry `db.system=postgresql`, `db.statement` (parameterized SQL with
literals replaced by placeholders), and `db.operation` (e.g. `INSERT`,
`SELECT`).
#### Repository code is unchanged
Once `cfg.ConnConfig.Tracer` is set, repository code just uses `pool.Query`
and `pool.QueryRow` normally. Spans appear as long as you pass `ctx` through
to the query call:
```go title="app/repository/article.go" showLineNumbers
func (r *ArticleRepository) GetByID(ctx context.Context, id int64) (*model.Article, error) {
var a model.Article
err := r.pool.QueryRow(ctx, `
SELECT id, title, body, created_at, updated_at
FROM articles WHERE id = $1
`, id).Scan(&a.ID, &a.Title, &a.Body, &a.CreatedAt, &a.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &a, nil
}
```
#### slog bridged to OTel logs with trace context
```go title="app/middleware/logger.go" showLineNumbers
package middleware
import (
"context"
"log/slog"
"os"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel/trace"
)
// NewLogger returns a slog.Logger that writes JSON to stdout AND bridges to
// the OTel logs pipeline. Trace and span IDs from context are added to every
// record so logs correlate with traces in Scout.
func NewLogger(serviceName string) *slog.Logger {
stdoutHandler := traceContextHandler{
Handler: slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}),
}
otelHandler := otelslog.NewHandler(serviceName)
return slog.New(multiHandler{handlers: []slog.Handler{stdoutHandler, otelHandler}}).
With("service", serviceName)
}
type traceContextHandler struct {
slog.Handler
}
func (h traceContextHandler) Handle(ctx context.Context, r slog.Record) error {
sc := trace.SpanFromContext(ctx).SpanContext()
if sc.IsValid() {
r.AddAttrs(
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
)
}
return h.Handler.Handle(ctx, r)
}
```
Two important details:
1. The `multiHandler` fans out every record to *both* the stdout JSON
handler and the OTel bridge, so you can `docker compose logs -f app` for
local tail and still ship logs to Scout.
2. `traceContextHandler` wraps the JSON handler so the stdout copy also
gets `trace_id`/`span_id`. The OTel bridge attaches them on its side
independently. Both copies stay correlated.
### Custom Instrumentation
#### Counter metric on a successful create
```go title="app/main.go" showLineNumbers
createdCounter, err := otel.Meter("stdlib-articles").Int64Counter("articles.created")
if err != nil {
log.Fatalf("counter: %v", err)
}
```
```go title="app/handler/article.go" showLineNumbers
article, err := h.repo.Create(r.Context(), req.Title, req.Body)
if err != nil {
h.logger.ErrorContext(r.Context(), "Failed to create article", "error", err)
writeError(r.Context(), w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create article")
return
}
h.created.Add(r.Context(), 1)
h.logger.InfoContext(r.Context(), "Article created", "article_id", article.ID)
```
The counter exports every 60 s through the meter provider. In Scout this
shows up as the `articles.created` metric, broken down by
`service.name=stdlib-articles` and `deployment.environment`.
#### Trace ID echoed in the response
API consumers can quote the trace ID in support tickets so you can pull
the full trace in Scout without grepping logs:
```go title="app/handler/article.go" showLineNumbers
func envelope(ctx context.Context, data any) map[string]any {
return map[string]any{
"data": data,
"meta": map[string]any{"trace_id": traceID(ctx)},
}
}
func traceID(ctx context.Context) string {
sc := trace.SpanFromContext(ctx).SpanContext()
if !sc.IsValid() {
return ""
}
return sc.TraceID().String()
}
```
A successful `POST /api/articles` then returns:
```json
{
"data": { "id": 42, "title": "...", "body": "..." },
"meta": { "trace_id": "0af7651916cd43dd8448eb211c80319c" }
}
```
#### Manual spans for business logic
For arbitrary work you want to time, grab a tracer once and start spans
explicitly:
```go
import "go.opentelemetry.io/otel"
tracer := otel.Tracer("stdlib-articles")
func chargeCustomer(ctx context.Context, customerID string) error {
ctx, span := tracer.Start(ctx, "charge_customer")
defer span.End()
span.SetAttributes(
attribute.String("customer.id", customerID),
)
if err := stripeCharge(ctx, customerID); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "stripe charge failed")
return err
}
return nil
}
```
`tracer.Start` automatically becomes a child of whatever span lives in
`ctx` (server span, parent business span, etc.).
### Running Your Application
#### Local with Docker Compose
```bash
git clone https://github.com/base-14/examples.git
cd examples/go/stdlib-postgres
cp .env.example .env
# edit .env with your Scout credentials (or leave defaults for local-only)
docker compose up -d --build
```
#### Smoke test the API
The example ships an end-to-end test script that exercises every endpoint,
checks distributed trace correlation, verifies log fields, and confirms
the `articles.created` metric reaches the collector:
```bash
make test-api
```
#### Verify a single request manually
```bash
# Health
curl http://localhost:8080/api/health
# Create an article (triggers the notify call)
curl -X POST http://localhost:8080/api/articles \
-H 'Content-Type: application/json' \
-d '{"title":"hello","body":"first article"}'
# Response includes trace_id you can search in Scout
```
#### Expected span hierarchy in Scout
For a `POST /api/articles` call you should see:
```text
POST /api/articles service.name=stdlib-articles
├── pool.acquire instrumentation_scope=otelpgx
├── prepare instrumentation_scope=otelpgx
├── query INSERT INTO articles ... db.statement, db.operation=INSERT
└── HTTP POST http.client, traceparent injected
└── POST /notify service.name=stdlib-notify
```
All five spans share a single `trace_id`. The notify service appears as a
sibling resource (`service.name=stdlib-notify`) inside the same trace.
#### Run a single service in dev mode
For tight feedback loops without rebuilding the container, run the binary
directly against an existing collector:
```bash
cd app
DATABASE_URL='postgres://postgres:postgres@localhost:5432/stdlib_articles?sslmode=disable' \
OTEL_SERVICE_NAME=stdlib-articles \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
NOTIFY_URL=http://localhost:8081/notify \
go run .
```
Note: when running outside Docker, swap the Compose hostnames (`db`,
`notify`, `otel-collector`) for `localhost`.
### Troubleshooting
#### Trace context is lost between services
**Symptom**: the notify service shows up as a separate trace instead of a
child of the articles service.
**Cause**: the outbound HTTP request was built with `http.NewRequest`
instead of `http.NewRequestWithContext`. The otelhttp transport pulls the
parent span out of `req.Context()`; without it, the transport starts a new
root trace.
**Fix**:
```go
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.url, bytes.NewReader(body))
```
Also confirm the global propagator is registered:
```go
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
```
#### pgx queries don't produce spans
**Symptom**: HTTP server spans appear, but no `query` or `pool.acquire`
spans show up underneath.
**Cause**: usually one of:
- `cfg.ConnConfig.Tracer = otelpgx.NewTracer()` was set on the wrong
config (e.g., on a fresh `pgx.Connect` config rather than the pool
config returned by `pgxpool.ParseConfig`).
- The repository code calls `pool.Query` with `context.Background()`
instead of the request context.
- The collector's `filter/noisy` processor is dropping `pool.acquire` and
`connect` spans intentionally (the example does this - check the config).
**Fix**: pass `r.Context()` through every repository method, and verify
the tracer is attached to the pool config before calling
`pgxpool.NewWithConfig`.
#### `otelhttp.NewHandler` is logging twice or producing wrong route names
**Symptom**: every request span is named `HTTP GET` or `HTTP POST`
without a path; route patterns are missing.
**Cause**: default span naming uses just the method. The mux pattern is
attached as an attribute but not in the name.
**Fix**: pass a span name formatter:
```go
otelhttp.NewHandler(mux, "http.server",
otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string {
return r.Method + " " + r.URL.Path
}),
)
```
For high-cardinality paths (e.g. `/api/articles/{id}`), prefer the route
pattern over the raw URL path:
```go
otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string {
if r.Pattern != "" {
return r.Method + " " + r.Pattern
}
return r.Method + " " + r.URL.Path
})
```
`http.Request.Pattern` (Go 1.23+) holds the matched mux route, so paths
like `/api/articles/42` collapse to `GET /api/articles/{id}` in span names.
#### OTLP export fails with `connection refused`
**Symptom**: stderr shows `failed to export traces: ... connect: connection
refused`.
**Cause**: the collector isn't reachable at the configured endpoint.
**Fix checklist**:
1. `docker compose ps` - is `otel-collector` running and healthy?
2. From the app container, `wget -O- http://otel-collector:13133` should
return the collector health endpoint.
3. `OTEL_EXPORTER_OTLP_ENDPOINT` must be the *base* URL
(`http://otel-collector:4318`), not the per-signal path.
4. Use `otlptracehttp.WithInsecure()` for plain HTTP between app and
collector. TLS is normally only used between collector and Scout.
#### Logs are missing `trace_id` and `span_id`
**Symptom**: stdout JSON logs land in the right shape but have no trace
correlation fields.
**Cause**: handlers are using `slog.Info` (no context) instead of
`slog.InfoContext(ctx, ...)`. Without the context argument, slog never
hits the `Handle(ctx, ...)` path that injects trace IDs.
**Fix**: thread context through every log call:
```go
h.logger.InfoContext(r.Context(), "Article created", "article_id", article.ID)
```
### Security Considerations
- **PII in span attributes** - `db.statement` carries the parameterized
SQL with literal values replaced. otelpgx already does this by default.
For HTTP attributes, otelhttp redacts the query string by default; do
not add `?token=...` style tokens to URLs.
- **Authorization headers** - otelhttp does not capture request or
response headers by default. If you opt in via
`otelhttp.WithFilter`/`otelhttp.WithPublicEndpoint`, add an explicit
redaction step for `Authorization`, `Cookie`, and `Set-Cookie`.
- **SQL parameter values** - otelpgx records `db.statement` with
placeholders (`$1`, `$2`); the actual values are *not* attached as span
attributes. If you ever switch to a tracer that does, route them through
a span processor that drops or hashes them.
- **Distroless attack surface** - shipping on
`gcr.io/distroless/static-debian12:nonroot` means no shell, no `apt`,
no setuid binaries, and a non-root UID. Vulnerability scanners
consistently report 0-2 CVEs against the runtime image vs. 30+ for a
full Debian/Ubuntu base.
- **Outbound TLS to Scout** - the collector's `oauth2client` extension
obtains a bearer token from Scout's token URL and renews it
automatically. Set `tls.insecure_skip_verify: true` only for local
testing; production should pin the Scout CA or accept the system trust
store.
- **Compliance scope** - if you need GDPR/HIPAA/SOC2 attestation for the
telemetry path, run the collector in your own VPC and use a private
link (Scout supports VPC peering). Avoid emitting end-user identifiers
as span attributes; use a hashed `user.id` if you need cardinality.
### Performance Considerations
The Go SDK is one of the fastest OTel SDKs in production, partly because
the runtime is goroutine-friendly and partly because everything is
library-based with no agent.
| Workload | Without OTel | With OTel | Overhead |
| ------------------------ | ------------ | --------- | ----------------------- |
| `GET /api/health` | 0.4 ms | 0.5 ms | +0.1 ms |
| `GET /api/articles/{id}` | 1.8 ms | 2.1 ms | +0.3 ms |
| `POST /api/articles` | 4.2 ms | 4.7 ms | +0.5 ms (incl. notify) |
| Postgres `SELECT` p99 | 0.9 ms | 1.0 ms | +0.1 ms (otelpgx) |
| RSS at idle | 12 MB | 22 MB | +10 MB (SDK + buffers) |
| CPU at 1k RPS | 8% | 11% | +3 percentage points |
(Numbers are illustrative; measure on your hardware.)
#### Tuning batch processors
The trace SDK batches by default (max 512 spans, 5 s timeout). For
high-throughput services you can raise the queue size to absorb traffic
spikes without dropping:
```go
sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExp,
sdktrace.WithMaxExportBatchSize(512),
sdktrace.WithMaxQueueSize(8192),
sdktrace.WithBatchTimeout(5*time.Second),
),
)
```
#### Filter health checks at the collector
Wrapping `/api/health` with otelhttp produces a span every time
Kubernetes probes the pod. The example collector drops these in the
traces pipeline so they never reach Scout:
```yaml title="config/otel-config.yaml" showLineNumbers
processors:
filter/noisy:
error_mode: ignore
traces:
span:
- 'IsMatch(name, ".*health.*")'
- 'name == "pool.acquire" and instrumentation_scope.name == "github.com/exaring/otelpgx"'
- 'name == "connect" and instrumentation_scope.name == "github.com/exaring/otelpgx"'
```
That cuts the trace volume by 60-80% on a typical microservice.
#### Goroutine cost
Each request runs in its own goroutine. The SDK's per-span allocations
are pooled, so a high-RPS service might allocate ~1 KB per span on hot
paths. The goroutine-local context plumbing adds negligible per-request
cost.
### FAQ
#### Why use net/http instead of a Go web framework with OpenTelemetry?
Go 1.22 added pattern-based routing (e.g. `GET /api/articles/{id}`) to
`net/http`, removing the main reason most teams reached for Echo, Fiber,
or Chi. Wrapping `ServeMux` with `otelhttp.NewHandler` gives you full
server-span coverage with zero framework dependency, no middleware
chains, and no router-specific OTel contrib package.
#### How do I trace pgx queries with OpenTelemetry?
Set `cfg.ConnConfig.Tracer = otelpgx.NewTracer()` before calling
`pgxpool.NewWithConfig`. Every query, prepare, and pool acquire becomes
a span attached to the parent HTTP server span via context. Pass `ctx`
to `pool.Query`, `pool.QueryRow`, and `pool.Exec`.
#### How much overhead does OpenTelemetry add to a Go net/http service?
Roughly 0.1-0.3 ms added latency per request, 1-3% CPU at 1k RPS, and
8-15 MB extra resident memory for the SDK. Postgres queries gain about
50-150 microseconds from the otelpgx tracer. The biggest variable is
network egress to the collector, not in-process overhead.
#### Does W3C traceparent propagate automatically between Go services?
Yes, when both client and server use otelhttp. Wrap the outbound
`http.Client` with `otelhttp.NewTransport` on the caller and
`otelhttp.NewHandler` on the receiver. The `traceparent` header is
injected and parsed automatically when you set `propagation.TraceContext`
as the global propagator.
#### How do I correlate Go slog logs with traces?
Use the `go.opentelemetry.io/contrib/bridges/otelslog` package to forward
records to the OTel logs pipeline, and a small custom `slog.Handler`
that reads `trace.SpanFromContext(ctx)` to add `trace_id` and `span_id`
to every record before the JSON encoder runs. The example does both via
a `multiHandler` that fans out to stdout and OTLP.
#### Can I run OpenTelemetry-instrumented Go binaries on distroless images?
Yes. The OTel Go SDK is pure Go, so a `CGO_ENABLED=0` static build runs
on `gcr.io/distroless/static-debian12:nonroot` with no extra packages.
The example app and notify service both use this image and the final
container is in the 17-20 MB range.
#### What is the difference between otelhttp.NewHandler and otelhttp.NewMiddleware?
`NewHandler` wraps an entire `http.Handler` (typically `ServeMux`) and
produces one server span per request. `NewMiddleware` returns a
middleware function for routers that compose middleware explicitly. For
stdlib `net/http`, `NewHandler` is the canonical entry point.
#### How do I expose the trace ID to API consumers in Go?
Read `trace.SpanFromContext(ctx).SpanContext().TraceID().String()` inside
the handler and embed it in the response body (for example in a `meta`
envelope). This lets clients quote the trace ID in support tickets so
you can pull the full trace in Scout. The example does this in the
`envelope()` helper.
#### Do I need a Go agent or auto-instrumentation binary?
No. Unlike Java or Python, Go instrumentation is library-based - you
import `otelhttp`, `otelpgx`, and `otelslog` and call them explicitly.
There is no `java -javaagent` equivalent for production Go binaries.
There is an experimental eBPF-based auto-instrumentation project, but
for stdlib services the library approach is simpler and faster.
#### How do I disable noisy spans like pgx pool.acquire?
Drop them at the collector with a `filter` processor on the traces
pipeline. The example config filters span names matching `.*health.*`
and `pool.acquire`/`connect` spans from otelpgx so the trace UI stays
focused on real query work. See the `filter/noisy` processor in
`config/otel-config.yaml`.
#### Can I use OpenTelemetry with database/sql instead of pgx?
Yes - swap otelpgx for `otelsql`
(`github.com/XSAM/otelsql`) and wrap your `sql.DB` driver. The rest of
the setup (otelhttp, otelslog, exporters) is identical. The example
uses pgx because pgxpool is the most widely-used native Postgres driver
for Go.
#### How do I stop sending dev OTLP traffic over the public internet?
Run the OTel collector locally in Docker Compose and point
`OTEL_EXPORTER_OTLP_ENDPOINT` at it (`http://otel-collector:4318`). The
collector authenticates to Scout with `oauth2client` and forwards over
TLS. Your laptop and your CI never need direct outbound TLS to Scout.
### What's Next
- **Add custom business metrics** - histograms for latency per business
flow, gauges for queue depth, async counters for cache hit rate.
See [custom instrumentation](../custom-instrumentation/go.md).
- **Wire up Postgres pool metrics** - otelpgx exposes
`pgxpool.Stat()`-derived gauges via a callback observer (open
connections, idle connections, acquire wait time).
- **Profile in production** - the SDK plays well with Go's `runtime/pprof`
and `net/http/pprof`. Start with span-level latency, then drop to
pprof for hot paths the spans surface.
- **Add Redis or NATS** - the same pattern works with `otelredis` and
`otelnats`. Wrap the client, pass `ctx`, you're done.
- **Move to gRPC** - swap `otelhttp` for `otelgrpc.NewServerHandler` and
`otelgrpc.NewClientHandler`. Same propagation, same span shape.
For end-to-end Go observability beyond a single service - log search,
trace exploration, alerting, and dashboarding without rolling your own
Grafana stack - see how teams use [Base14 Scout for production Go
observability](https://www.base14.io).
### Complete Example
The full reference implementation lives at
[go/stdlib-postgres](https://github.com/base-14/examples/tree/main/go/stdlib-postgres):
```text
go/stdlib-postgres/
├── app/ # stdlib-articles (port 8080)
│ ├── main.go # bootstraps OTel + pgx pool + mux
│ ├── telemetry.go # tracer + meter + logger providers
│ ├── handler/
│ │ ├── article.go # CRUD handlers + envelope helpers
│ │ └── health.go # /api/health
│ ├── middleware/
│ │ └── logger.go # slog handler with trace context
│ ├── model/
│ │ └── article.go # Article struct + Schema constant
│ ├── repository/
│ │ └── article.go # pgx queries
│ ├── service/
│ │ └── notification.go # otelhttp-instrumented client
│ ├── go.mod
│ ├── go.sum
│ └── Dockerfile # multi-stage, distroless
├── notify/ # stdlib-notify (port 8081)
│ ├── main.go # /notify endpoint, span receiver
│ ├── telemetry.go # mirrors app/telemetry.go
│ ├── logger.go # slog + otelslog bridge
│ ├── go.mod / go.sum / Dockerfile
├── config/
│ └── otel-config.yaml # collector w/ oauth2client → Scout
├── scripts/
│ ├── test-api.sh # full e2e API + observability check
│ └── verify-scout.sh # confirms data lands in Scout
├── compose.yml # app + notify + db + collector
├── Makefile # build, lint, docker-up, test-api
├── .env.example
└── README.md
```
#### Run it
```bash
git clone https://github.com/base-14/examples.git
cd examples/go/stdlib-postgres
cp .env.example .env
# fill in Scout credentials, or leave defaults for local-only
docker compose up -d --build
# all-in-one functional + observability smoke test
make test-api
# scout export verification (requires real credentials)
make verify-scout
```
The `test-api.sh` script does an end-to-end check: it runs every CRUD
endpoint, extracts the `trace_id` from a `POST /api/articles` response,
greps the notify service logs for the same trace ID, greps the collector
logs for matching spans, and waits for the periodic metric flush to
verify `articles.created` reaches the collector. A passing run looks
like:
```text
=== stdlib-postgres API Testing Script ===
Target: http://localhost:8080
[PASS] Health check (HTTP 200)
[PASS] Create article (HTTP 201)
[PASS] Get article (HTTP 200)
[PASS] List articles (HTTP 200)
[PASS] Update article (HTTP 200)
[PASS] Delete article (HTTP 204)
[PASS] 400 - Invalid ID format (HTTP 400)
[PASS] 404 - Article not found (HTTP 404)
[PASS] 422 - Empty body (HTTP 422)
[PASS] Distributed trace - notify service received matching trace_id
[PASS] Collector received spans with matching trace_id
[PASS] Logs contain trace_id field
[PASS] Logs contain span_id field
[PASS] WARN log present for error conditions
[PASS] articles.created metric found in collector
=== Results ===
Passed: 15 / 15
```
### References
- [OpenTelemetry Go SDK](https://opentelemetry.io/docs/languages/go/)
- [otelhttp contrib package](https://pkg.go.dev/go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp)
- [otelpgx](https://github.com/exaring/otelpgx)
- [otelslog bridge](https://pkg.go.dev/go.opentelemetry.io/contrib/bridges/otelslog)
- [pgx v5](https://github.com/jackc/pgx)
- [Go 1.22 ServeMux pattern routing](https://pkg.go.dev/net/http#ServeMux)
- [Distroless container images](https://github.com/GoogleContainerTools/distroless)
- [W3C Trace Context](https://www.w3.org/TR/trace-context/)
- [OpenTelemetry Collector oauth2client extension](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/extension/oauth2clientauthextension)
### Related Guides
- [Go Custom Instrumentation](../custom-instrumentation/go.md) - Manual spans
and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
- [Hello World](/instrument/apps/hello-world) - verify your collector
before adding any app code.
---
## Go OpenTelemetry Instrumentation - Echo, Fiber, Chi & GORM Tracing
## Go
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Introduction
Implement OpenTelemetry instrumentation for Go applications to enable
comprehensive application performance monitoring (APM), distributed tracing, and
observability across your high-performance web services. This guide shows you
how to instrument popular Go web frameworks including Echo, Fiber, and Chi,
along with database clients (GORM, sqlx), Redis, background job queues (Asynq,
River), and gRPC services using the OpenTelemetry Go SDK.
Go applications benefit from automatic instrumentation of HTTP servers, database
queries, Redis operations, gRPC calls, and message queues. With OpenTelemetry,
you can trace requests through goroutines, monitor context propagation, identify
slow database queries, debug concurrent operations, and track distributed
transactions across microservices with minimal performance overhead.
Whether you're implementing observability for the first time, migrating from
commercial APM solutions like New Relic or Datadog, troubleshooting performance
issues in production, or building high-throughput microservices, this guide
provides production-ready configurations and best practices for Go OpenTelemetry
instrumentation with Base14 Scout.
This guide covers OpenTelemetry for Go services. For a complete worked example
with the standard library and PostgreSQL, see the
[Go stdlib and PostgreSQL guide](./go-stdlib-postgres.md).
:::tip TL;DR
Initialize a `TracerProvider` once in `main`, set it as the global provider,
then add the framework middleware (e.g., `otelecho`, `otelfiber`, `otelhttp`)
to automatically trace every incoming request. Pass `context.Context` explicitly
through your call stack so database, Redis, and background-job spans attach to
the same trace.
:::
### Who This Guide Is For
This documentation is designed for:
- **Go developers**: implementing observability and distributed tracing for web
services and microservices
- **Backend engineers**: deploying Go applications with production monitoring
requirements and SLO tracking
- **DevOps teams**: standardizing observability across multiple Go services in
containerized environments
- **Performance engineers**: debugging goroutine bottlenecks, database query
performance, and concurrent operations
- **Microservices architects**: building observable distributed systems with
gRPC, message queues, and service meshes
### Prerequisites
Before starting, ensure you have:
- **Go 1.21 or later** (1.22+ recommended for production)
- **Web framework** (Echo, Fiber, Chi, Gin, or standard `net/http`)
- **Scout Collector** configured and accessible
- See
[Docker Compose Setup](../../collector-setup/docker-compose-example.md) for
local development
- See [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md)
for production
- Basic understanding of OpenTelemetry concepts (traces, spans, attributes)
- Familiarity with Go context propagation and middleware patterns
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version |
| -------------------------------- | --------------- | ------------------- |
| Go | 1.20 | 1.22+ |
| go.opentelemetry.io/otel | 1.20.0 | 1.32+ |
| go.opentelemetry.io/contrib | 1.20.0 | 1.32+ |
| Echo (labstack/echo) | 4.10.0 | 4.13.0+ |
| Fiber (gofiber/fiber) | 2.50.0 | 2.52.0+ |
| Chi (go-chi/chi) | 5.0.0 | 5.1.0+ |
| GORM | 1.25.0 | 1.25.12+ |
| sqlx (jmoiron/sqlx) | 1.3.0 | 1.4.0+ |
| Redis (go-redis/redis) | 9.0.0 | 9.7.0+ |
| Asynq (hibiken/asynq) | 0.24.0 | 0.24.1+ |
| River (riverqueue/river) | 0.11.0 | 0.14.0+ |
### Installation
Install the OpenTelemetry SDK and instrumentation packages:
```bash showLineNumbers title="Install OpenTelemetry for Go"
go get go.opentelemetry.io/otel \
go.opentelemetry.io/otel/sdk \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp \
go.opentelemetry.io/otel/sdk/resource \
go.opentelemetry.io/otel/semconv/v1.26.0
```
Install framework-specific instrumentation:
```bash showLineNumbers title="Install framework instrumentation"
# Echo framework
go get go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho
# Fiber framework
go get go.opentelemetry.io/contrib/instrumentation/github.com/gofiber/fiber/v2/otelfiber
# Chi router (manual middleware)
go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
# GORM
go get go.opentelemetry.io/contrib/instrumentation/gorm.io/gorm/otelgorm
# Database drivers
go get go.opentelemetry.io/contrib/instrumentation/database/sql/otelsql
# Redis
go get go.opentelemetry.io/contrib/instrumentation/github.com/go-redis/redis/v9/otelredis
# gRPC
go get go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc
```
### Configuration
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
Create a dedicated package for OpenTelemetry initialization:
```go showLineNumbers title="internal/tracing/tracing.go"
package tracing
import (
"context"
"log"
"os"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
func InitTracer(serviceName, serviceVersion string) (func(context.Context) error, error) {
ctx := context.Background()
// Create OTLP HTTP exporter
exporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint(getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4318")),
otlptracehttp.WithInsecure(), // Use WithTLSClientConfig for production
)
if err != nil {
return nil, err
}
// Create resource with service information
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(serviceName),
semconv.ServiceVersion(serviceVersion),
semconv.DeploymentEnvironment(getEnv("DEPLOYMENT_ENV", "development")),
),
resource.WithHost(),
resource.WithOS(),
resource.WithProcess(),
resource.WithContainer(),
)
if err != nil {
return nil, err
}
// Create tracer provider with batch span processor
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter,
sdktrace.WithMaxQueueSize(2048),
sdktrace.WithMaxExportBatchSize(512),
sdktrace.WithBatchTimeout(5*time.Second),
),
sdktrace.WithResource(res),
)
// Set global tracer provider
otel.SetTracerProvider(tp)
// Set global propagator for context propagation
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
log.Println("OpenTelemetry initialized successfully")
// Return shutdown function
return tp.Shutdown, nil
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
```
Use in your main application:
```go showLineNumbers title="cmd/server/main.go"
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/labstack/echo/v4"
"go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho"
"your-module/internal/tracing"
)
func main() {
// Initialize OpenTelemetry
shutdown, err := tracing.InitTracer("echo-api", "1.0.0")
if err != nil {
log.Fatalf("Failed to initialize tracer: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := shutdown(ctx); err != nil {
log.Printf("Error shutting down tracer: %v", err)
}
}()
// Create Echo server with tracing middleware
e := echo.New()
e.Use(otelecho.Middleware("echo-api"))
// Define routes
e.GET("/", handleRoot)
e.GET("/users/:id", handleGetUser)
// Start server with graceful shutdown
go func() {
if err := e.Start(":8080"); err != nil {
log.Printf("Server error: %v", err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := e.Shutdown(ctx); err != nil {
log.Fatal(err)
}
}
func handleRoot(c echo.Context) error {
return c.String(200, "Hello, World!")
}
func handleGetUser(c echo.Context) error {
return c.JSON(200, map[string]string{"id": c.Param("id")})
}
```
```mdx-code-block
```
For containerized deployments:
```bash showLineNumbers title=".env"
# Service identification
OTEL_SERVICE_NAME=go-api
OTEL_SERVICE_VERSION=1.0.0
DEPLOYMENT_ENV=development
# Exporter configuration
OTEL_EXPORTER_OTLP_ENDPOINT=scout-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# Resource attributes
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development,environment=development,service.namespace=backend
# Sampling (optional)
OTEL_TRACES_SAMPLER=parentbased_always_on
```
```mdx-code-block
```
```go showLineNumbers title="internal/tracing/production.go"
package tracing
import (
"context"
"crypto/tls"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
func InitProductionTracer(serviceName string) (func(context.Context) error, error) {
ctx := context.Background()
// Create OTLP exporter with TLS
exporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint(os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")),
otlptracehttp.WithTLSClientConfig(&tls.Config{
MinVersion: tls.VersionTLS12,
}),
otlptracehttp.WithHeaders(map[string]string{
"Authorization": "Bearer " + os.Getenv("SCOUT_API_KEY"),
}),
)
if err != nil {
return nil, err
}
// Resource with Kubernetes/container metadata
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(serviceName),
semconv.ServiceVersion(os.Getenv("SERVICE_VERSION")),
semconv.DeploymentEnvironment(os.Getenv("DEPLOYMENT_ENV")),
semconv.ServiceInstanceID(os.Getenv("HOSTNAME")),
semconv.K8SPodName(os.Getenv("K8S_POD_NAME")),
semconv.K8SNamespaceName(os.Getenv("K8S_NAMESPACE")),
semconv.ContainerID(os.Getenv("CONTAINER_ID")),
),
resource.WithHost(),
resource.WithProcess(),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter,
sdktrace.WithMaxQueueSize(2048),
sdktrace.WithMaxExportBatchSize(512),
),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.AlwaysSample())),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
return tp.Shutdown, nil
}
```
```mdx-code-block
```
### Framework-Specific Instrumentation
```mdx-code-block
```
#### Echo Framework
```go showLineNumbers title="Echo with GORM and Asynq"
package main
import (
"github.com/labstack/echo/v4"
"go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho"
"go.opentelemetry.io/contrib/instrumentation/gorm.io/gorm/otelgorm"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func main() {
// Initialize tracing
shutdown, _ := tracing.InitTracer("echo-api", "1.0.0")
defer shutdown(context.Background())
// Setup GORM with OpenTelemetry
db, err := gorm.Open(postgres.Open(os.Getenv("DATABASE_URL")), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
// Add GORM OpenTelemetry plugin
if err := db.Use(otelgorm.NewPlugin()); err != nil {
log.Fatal(err)
}
// Create Echo server
e := echo.New()
// Add OpenTelemetry middleware (should be first)
e.Use(otelecho.Middleware("echo-api"))
// Routes
e.GET("/users", func(c echo.Context) error {
var users []User
// This query is automatically traced
if err := db.WithContext(c.Request().Context()).Find(&users).Error; err != nil {
return err
}
return c.JSON(200, users)
})
e.GET("/users/:id", func(c echo.Context) error {
var user User
// Context propagation maintains trace hierarchy
if err := db.WithContext(c.Request().Context()).
First(&user, c.Param("id")).Error; err != nil {
return err
}
return c.JSON(200, user)
})
e.Start(":8080")
}
type User struct {
ID uint `gorm:"primaryKey"`
Name string
Email string
}
```
```mdx-code-block
```
#### Fiber Framework
```go showLineNumbers title="Fiber with sqlx and River"
package main
import (
"github.com/gofiber/fiber/v2"
"go.opentelemetry.io/contrib/instrumentation/github.com/gofiber/fiber/v2/otelfiber"
"go.opentelemetry.io/contrib/instrumentation/database/sql/otelsql"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
func main() {
// Initialize tracing
shutdown, _ := tracing.InitTracer("fiber-api", "1.0.0")
defer shutdown(context.Background())
// Setup sqlx with OpenTelemetry
db, err := otelsql.Open("postgres", os.Getenv("DATABASE_URL"),
otelsql.WithAttributes(
semconv.DBSystemPostgreSQL,
),
)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Register stats for monitoring
if err := otelsql.RegisterDBStatsMetrics(db, otelsql.WithAttributes(
semconv.DBSystemPostgreSQL,
)); err != nil {
log.Fatal(err)
}
sqlxDB := sqlx.NewDb(db, "postgres")
// Create Fiber app
app := fiber.New()
// Add OpenTelemetry middleware
app.Use(otelfiber.Middleware())
// Routes
app.Get("/users", func(c *fiber.Ctx) error {
ctx := c.UserContext()
var users []User
// Query is automatically traced
if err := sqlxDB.SelectContext(ctx, &users, "SELECT * FROM users"); err != nil {
return err
}
return c.JSON(users)
})
app.Get("/users/:id", func(c *fiber.Ctx) error {
ctx := c.UserContext()
id := c.Params("id")
var user User
// Context propagation maintains trace
if err := sqlxDB.GetContext(ctx, &user,
"SELECT * FROM users WHERE id = $1", id); err != nil {
return err
}
return c.JSON(user)
})
app.Listen(":8080")
}
```
```mdx-code-block
```
#### Chi Router
```go showLineNumbers title="Chi with custom middleware"
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func main() {
// Initialize tracing
shutdown, _ := tracing.InitTracer("chi-api", "1.0.0")
defer shutdown(context.Background())
r := chi.NewRouter()
// Add OpenTelemetry middleware
r.Use(func(next http.Handler) http.Handler {
return otelhttp.NewHandler(next, "chi-api")
})
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, World!"))
})
r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "id")
// Custom span for business logic
ctx := r.Context()
tracer := otel.Tracer("chi-api")
ctx, span := tracer.Start(ctx, "getUserByID")
defer span.End()
span.SetAttributes(attribute.String("user.id", userID))
// Your business logic here
w.Write([]byte("User: " + userID))
})
http.ListenAndServe(":8080", r)
}
```
```mdx-code-block
```
### Database Instrumentation
```mdx-code-block
```
#### GORM (Echo/Gin)
```go showLineNumbers title="GORM with OpenTelemetry plugin"
package database
import (
"gorm.io/driver/postgres"
"gorm.io/gorm"
"go.opentelemetry.io/contrib/instrumentation/gorm.io/gorm/otelgorm"
)
func NewDB(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
// Add OpenTelemetry plugin
if err := db.Use(otelgorm.NewPlugin(
otelgorm.WithDBName("myapp"),
otelgorm.WithAttributes(
semconv.DBSystemPostgreSQL,
),
)); err != nil {
return nil, err
}
return db, nil
}
// Usage in handler
func GetUsers(c echo.Context) error {
ctx := c.Request().Context()
var users []User
// Automatically traced query
if err := db.WithContext(ctx).
Preload("Orders").
Where("active = ?", true).
Find(&users).Error; err != nil {
return err
}
return c.JSON(200, users)
}
```
```mdx-code-block
```
#### sqlx (Fiber)
```go showLineNumbers title="sqlx with otelsql"
package database
import (
"database/sql"
"github.com/jmoiron/sqlx"
"go.opentelemetry.io/contrib/instrumentation/database/sql/otelsql"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
func NewSqlxDB(dsn string) (*sqlx.DB, error) {
// Register driver with OpenTelemetry
db, err := otelsql.Open("postgres", dsn,
otelsql.WithAttributes(semconv.DBSystemPostgreSQL),
otelsql.WithSpanOptions(otelsql.SpanOptions{
Ping: true,
RowsNext: true,
DisableErrSkip: true,
DisableQuery: false,
QueryFormatter: nil,
}),
)
if err != nil {
return nil, err
}
// Register DB stats metrics
if err := otelsql.RegisterDBStatsMetrics(db,
otelsql.WithAttributes(semconv.DBSystemPostgreSQL)); err != nil {
return nil, err
}
return sqlx.NewDb(db, "postgres"), nil
}
// Repository pattern with context
type UserRepository struct {
db *sqlx.DB
}
func (r *UserRepository) GetByID(ctx context.Context, id int) (*User, error) {
var user User
// Query is automatically traced
err := r.db.GetContext(ctx, &user,
`SELECT id, name, email FROM users WHERE id = $1`, id)
return &user, err
}
func (r *UserRepository) List(ctx context.Context) ([]User, error) {
var users []User
// Automatically traced
err := r.db.SelectContext(ctx, &users,
`SELECT id, name, email FROM users ORDER BY created_at DESC`)
return users, err
}
```
```mdx-code-block
```
### Redis Instrumentation
```go showLineNumbers title="Redis with OpenTelemetry"
package cache
import (
"context"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/github.com/redis/go-redis/v9/redisotel"
)
func NewRedisClient(addr string) *redis.Client {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
})
// Add OpenTelemetry hooks
if err := redisotel.InstrumentTracing(rdb); err != nil {
panic(err)
}
// Optional: Add metrics
if err := redisotel.InstrumentMetrics(rdb); err != nil {
panic(err)
}
return rdb
}
// Usage in service
type CacheService struct {
redis *redis.Client
}
func (s *CacheService) Get(ctx context.Context, key string) (string, error) {
// Automatically traced
return s.redis.Get(ctx, key).Result()
}
func (s *CacheService) Set(ctx context.Context, key, value string, ttl time.Duration) error {
// Automatically traced
return s.redis.Set(ctx, key, value, ttl).Err()
}
```
### Background Jobs
#### Asynq (Task Queue)
```go showLineNumbers title="Asynq with tracing"
package jobs
import (
"context"
"encoding/json"
"github.com/hibiken/asynq"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
const TypeEmailDelivery = "email:delivery"
type EmailPayload struct {
UserID int
Email string
}
// Task creation with trace context
func NewEmailDeliveryTask(ctx context.Context, userID int, email string) (*asynq.Task, error) {
tracer := otel.Tracer("asynq-tasks")
_, span := tracer.Start(ctx, "CreateEmailTask")
defer span.End()
payload, err := json.Marshal(EmailPayload{
UserID: userID,
Email: email,
})
if err != nil {
span.RecordError(err)
return nil, err
}
span.SetAttributes(
attribute.Int("user.id", userID),
attribute.String("task.type", TypeEmailDelivery),
)
return asynq.NewTask(TypeEmailDelivery, payload), nil
}
// Handler with tracing
func HandleEmailDeliveryTask(ctx context.Context, t *asynq.Task) error {
tracer := otel.Tracer("asynq-handler")
ctx, span := tracer.Start(ctx, "HandleEmailDelivery")
defer span.End()
var p EmailPayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
span.RecordError(err)
return err
}
span.SetAttributes(
attribute.Int("user.id", p.UserID),
attribute.String("email", p.Email),
)
// Send email
if err := sendEmail(ctx, p.Email); err != nil {
span.RecordError(err)
return err
}
return nil
}
func sendEmail(ctx context.Context, email string) error {
tracer := otel.Tracer("email-sender")
_, span := tracer.Start(ctx, "sendEmail")
defer span.End()
// Email sending logic
span.SetAttributes(attribute.String("email.to", email))
return nil
}
```
#### River (PostgreSQL-native queue)
```go showLineNumbers title="River with tracing"
package jobs
import (
"context"
"github.com/riverqueue/river"
"go.opentelemetry.io/otel"
)
type EmailArgs struct {
UserID int
Email string
}
func (EmailArgs) Kind() string { return "email" }
type EmailWorker struct {
river.WorkerDefaults[EmailArgs]
}
func (w *EmailWorker) Work(ctx context.Context, job *river.Job[EmailArgs]) error {
tracer := otel.Tracer("river-worker")
ctx, span := tracer.Start(ctx, "EmailWorker.Work")
defer span.End()
span.SetAttributes(
attribute.Int("user.id", job.Args.UserID),
attribute.String("job.kind", job.Kind),
attribute.Int("job.attempt", job.Attempt),
)
// Process email
if err := processEmail(ctx, job.Args); err != nil {
span.RecordError(err)
return err
}
return nil
}
```
### Custom Instrumentation
For business logic and application-specific operations:
```go showLineNumbers title="services/order_service.go"
package services
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
type OrderService struct {
db *gorm.DB
cache *redis.Client
}
func (s *OrderService) CreateOrder(ctx context.Context, userID int, items []OrderItem) (*Order, error) {
tracer := otel.Tracer("order-service")
ctx, span := tracer.Start(ctx, "OrderService.CreateOrder")
defer span.End()
span.SetAttributes(
attribute.Int("user.id", userID),
attribute.Int("items.count", len(items)),
)
// Validate inventory
ctx, validateSpan := tracer.Start(ctx, "validateInventory")
available, err := s.checkInventory(ctx, items)
if err != nil {
validateSpan.RecordError(err)
validateSpan.SetStatus(codes.Error, "inventory check failed")
validateSpan.End()
return nil, err
}
validateSpan.SetAttributes(attribute.Bool("inventory.available", available))
validateSpan.End()
if !available {
span.SetStatus(codes.Error, "insufficient inventory")
return nil, ErrInsufficientInventory
}
// Create order
ctx, createSpan := tracer.Start(ctx, "createOrderRecord")
order := &Order{
UserID: userID,
Items: items,
Total: calculateTotal(items),
}
if err := s.db.WithContext(ctx).Create(order).Error; err != nil {
createSpan.RecordError(err)
createSpan.SetStatus(codes.Error, "database error")
createSpan.End()
return nil, err
}
createSpan.SetAttributes(attribute.Int("order.id", int(order.ID)))
createSpan.End()
// Process payment
ctx, paymentSpan := tracer.Start(ctx, "processPayment")
if err := s.processPayment(ctx, order.ID, order.Total); err != nil {
paymentSpan.RecordError(err)
paymentSpan.SetStatus(codes.Error, "payment failed")
paymentSpan.End()
return nil, err
}
paymentSpan.End()
span.SetStatus(codes.Ok, "order created successfully")
return order, nil
}
func (s *OrderService) checkInventory(ctx context.Context, items []OrderItem) (bool, error) {
// Business logic
return true, nil
}
func (s *OrderService) processPayment(ctx context.Context, orderID uint, amount float64) error {
tracer := otel.Tracer("payment-service")
_, span := tracer.Start(ctx, "processPayment")
defer span.End()
span.SetAttributes(
attribute.Int("order.id", int(orderID)),
attribute.Float64("payment.amount", amount),
)
// Payment processing logic
return nil
}
func calculateTotal(items []OrderItem) float64 {
var total float64
for _, item := range items {
total += item.Price * float64(item.Quantity)
}
return total
}
```
### Running Your Application
#### Development Mode
```bash showLineNumbers
# With environment variables
export OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4318
export DEPLOYMENT_ENV=development
go run cmd/server/main.go
```
#### Production Mode
```bash showLineNumbers
# Build optimized binary
go build -ldflags="-s -w" -o server cmd/server/main.go
# Run with production config
export OTEL_SERVICE_NAME=go-api
export OTEL_EXPORTER_OTLP_ENDPOINT=scout-collector:4318
export DEPLOYMENT_ENV=development
./server
```
#### Docker Deployment
```dockerfile showLineNumbers title="Dockerfile"
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server cmd/server/main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/server .
ENV OTEL_SERVICE_NAME=go-api
ENV DEPLOYMENT_ENV=demo
EXPOSE 8080
CMD ["./server"]
```
```yaml showLineNumbers title="docker-compose.yml"
version: '3.8'
services:
go-api:
build: .
ports:
- '8080:8080'
environment:
- OTEL_SERVICE_NAME=go-api
- OTEL_SERVICE_VERSION=1.0.0
- OTEL_EXPORTER_OTLP_ENDPOINT=scout-collector:4318
- DATABASE_URL=postgres://user:pass@postgres:5432/godb
- REDIS_URL=redis:6379
depends_on:
- postgres
- redis
- scout-collector
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: godb
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
redis:
image: redis:7-alpine
scout-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ['--config=/etc/otel-collector-config.yaml']
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- '4318:4318'
```
### Troubleshooting
#### Issue: No Traces Appearing in Scout
**Solutions:**
1. Verify collector connectivity:
```go
// Test exporter connection
exporter, err := otlptracehttp.New(context.Background(),
otlptracehttp.WithEndpoint("localhost:4318"),
otlptracehttp.WithInsecure(),
)
if err != nil {
log.Fatal("Failed to create exporter:", err)
}
```
1. Enable debug logging:
```go
import "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
// Use console exporter for debugging
exporter, _ := stdouttrace.New(stdouttrace.WithPrettyPrint())
```
1. Verify tracer provider is set:
```go
if otel.GetTracerProvider() == nil {
log.Fatal("Tracer provider not initialized")
}
```
#### Issue: Missing Context in Goroutines
**Solutions:**
Always pass context to goroutines:
```go
// WRONG - loses trace context
go func() {
span := trace.SpanFromContext(context.Background()) // nil!
}()
// CORRECT - preserve context
go func(ctx context.Context) {
span := trace.SpanFromContext(ctx) // Works!
}(ctx)
```
#### Issue: Database Queries Not Traced
**Solutions:**
1. Ensure you're using `WithContext`:
```go
// WRONG - no context
db.Find(&users)
// CORRECT - with context
db.WithContext(ctx).Find(&users)
```
1. For sqlx, use `Context` methods:
```go
// WRONG
db.Select(&users, query)
// CORRECT
db.SelectContext(ctx, &users, query)
```
#### Issue: High Memory Usage
**Solutions:**
Reduce batch sizes and queue limits:
```go
sdktrace.WithBatcher(exporter,
sdktrace.WithMaxQueueSize(1024), // Reduced
sdktrace.WithMaxExportBatchSize(256), // Reduced
)
```
### Performance Considerations
OpenTelemetry adds minimal overhead to Go applications:
**Expected Impact:**
- **Latency**: +0.1-0.5ms per request
- **CPU**: +1-3% in production
- **Memory**: +5-15MB for trace buffers
- **Goroutines**: +2-3 additional goroutines for exporter
#### Optimization Best Practices
##### 1. Use Batch Span Processor
```go showLineNumbers
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter,
sdktrace.WithMaxQueueSize(2048),
sdktrace.WithBatchTimeout(5*time.Second),
),
)
```
##### 2. Skip Health Check Endpoints
```go showLineNumbers
app.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip tracing for health checks
if r.URL.Path == "/health" || r.URL.Path == "/metrics" {
next.ServeHTTP(w, r)
return
}
otelhttp.NewHandler(next, "api").ServeHTTP(w, r)
})
})
```
##### 3. Disable Query Parameter Logging
```go showLineNumbers
otelsql.Open("postgres", dsn,
otelsql.WithSpanOptions(otelsql.SpanOptions{
DisableQuery: true, // Don't log full queries
}),
)
```
##### 5. Reuse HTTP Clients
```go showLineNumbers
// Create once, reuse
var httpClient = &http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport),
}
```
### Security Considerations
#### Sensitive Data Protection
```go showLineNumbers
// BAD - Exposes sensitive data
span.SetAttributes(
attribute.String("user.password", password),
attribute.String("credit_card", ccNumber),
)
// GOOD - Use safe identifiers
span.SetAttributes(
attribute.Int("user.id", userID),
attribute.String("payment.method", "credit_card"),
)
```
#### SQL Query Sanitization
```go showLineNumbers
// Parameters are automatically sanitized by otelsql
db.SelectContext(ctx, &users,
"SELECT * FROM users WHERE email = $1", email) // Safe
```
### FAQ
#### Does OpenTelemetry work with all Go web frameworks?
Yes, OpenTelemetry supports Echo, Fiber, Chi, Gin, and standard `net/http`
through various instrumentation packages.
#### What is the performance impact of OpenTelemetry on Go applications?
Minimal: +0.1-0.5ms latency, +1-3% CPU, +5-15MB memory. Go's efficient
goroutines make OpenTelemetry very lightweight.
#### Can I trace GORM, sqlx, and standard database/sql?
Yes, use `otelgorm` for GORM, `otelsql` for sqlx and database/sql. All SQL
queries are automatically traced.
#### How do I propagate OpenTelemetry trace context across goroutines in Go?
Pass context to goroutines: `go func(ctx context.Context) { ... }(ctx)` to
maintain trace hierarchy.
#### Does OpenTelemetry work with gRPC in Go?
Yes, use `go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc`
for automatic gRPC tracing.
#### Can I trace Asynq and River background jobs?
Yes, add custom spans in task handlers using `otel.Tracer()` as shown in the
Background Jobs section.
#### How do I handle context propagation across services?
OpenTelemetry automatically propagates context via HTTP headers using W3C Trace
Context and Baggage.
#### What's the difference between traces and metrics?
Traces show request flow through your app. Metrics aggregate performance data.
Both are supported by OpenTelemetry.
#### Can I trace GORM and sqlx database queries with OpenTelemetry in Go?
Yes. Use `otelgorm` for GORM and `otelsql` for sqlx and `database/sql`. Each
query becomes a span with the SQL recorded on it. Set `DisableQuery: true` in
`otelsql.SpanOptions` to keep the query text out of spans.
### What's Next?
#### Framework-Specific Examples
- [Echo Example](https://github.com/base-14/examples/tree/main/go/echo-postgres)
\- Echo + GORM + Asynq
- [Fiber Example](https://github.com/base-14/examples/tree/main/go/fiber-postgres)
\- Fiber + sqlx + River
- [Chi Example](https://github.com/base-14/examples/tree/main/go/chi-inmemory)
\- Chi + in-memory
#### Advanced Topics
- [Custom Go Instrumentation](../custom-instrumentation/go.md) - Advanced
patterns and custom exporters
#### Scout Platform Features
- [Creating Alerts](../../../guides/creating-alerts-with-logx.md) - Set up
alerts for latency and errors
- [Dashboard Creation](../../../guides/create-your-first-dashboard.md) - Build
custom dashboards
#### Deployment and Operations
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) -
Local development environment
- [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md) -
Production deployment
### Complete Example
```bash showLineNumbers title="go.mod"
module github.com/example/go-api
go 1.22
require (
github.com/labstack/echo/v4 v4.13.0
go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.54.0
go.opentelemetry.io/contrib/instrumentation/gorm.io/gorm/otelgorm v0.54.0
go.opentelemetry.io/otel v1.32.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0
go.opentelemetry.io/otel/sdk v1.32.0
gorm.io/driver/postgres v1.5.12
gorm.io/gorm v1.25.12
)
```
Complete working examples:
[GitHub: base-14/examples/go](https://github.com/base-14/examples/tree/main/go)
With traces flowing, you can
[explore Go service traces in Scout](https://base14.io/scout/traces)
— follow requests across HTTP handlers, gRPC calls, and database
operations with full distributed context.
### References
- [Official OpenTelemetry Go Documentation](https://opentelemetry.io/docs/languages/go/)
- [OpenTelemetry Go SDK](https://pkg.go.dev/go.opentelemetry.io/otel)
- [OpenTelemetry Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/)
### Related Guides
- [Go Custom Instrumentation](../custom-instrumentation/go.md) - Manual spans
and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) -
Local development
- [Kubernetes Deployment](../../collector-setup/kubernetes-helm-setup.md) -
Production setup
---
## Hono OpenTelemetry Instrumentation - HTTP, Database & Redis Tracing
## Hono
Implement OpenTelemetry instrumentation for Hono applications to enable
comprehensive application performance monitoring (APM), distributed tracing,
and observability. This guide shows you how to auto-instrument your Hono
application to collect traces and metrics from HTTP requests, database queries,
Redis operations, background jobs, and custom business logic using the
OpenTelemetry Node.js SDK with minimal code changes.
Hono applications benefit from automatic instrumentation of HTTP and database
layers, combined with the `@hono/otel` middleware for framework-specific span
generation. With OpenTelemetry, you can monitor production performance, debug
slow requests, trace distributed transactions across microservices, and
identify database query bottlenecks. Hono's lightweight design and middleware
architecture work seamlessly with OpenTelemetry's context propagation,
ensuring accurate parent-child span relationships across async operations
including BullMQ background jobs.
As a lightweight, middleware-first framework, Hono is a leaner alternative to
[Express](./express.md) and shares its modern design with
[Elysia](./elysia.md) on the Bun runtime. The instrumentation approach in this
guide carries over to both.
Whether you're implementing observability for the first time, migrating from
commercial APM solutions like DataDog or New Relic, or troubleshooting
performance issues in production, this guide provides production-ready
configurations and best practices for Hono OpenTelemetry instrumentation.
:::tip TL;DR
Create a `telemetry.ts` file, initialize the OpenTelemetry Node.js SDK there,
and import it as the very first line of your entry point before any other
module. Add `@hono/otel` middleware and `@opentelemetry/auto-instrumentations-node`
to get automatic HTTP, PostgreSQL, and Redis spans with no additional
instrumentation code.
:::
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Overview
This guide demonstrates how to:
- Set up OpenTelemetry instrumentation for Hono applications
- Configure automatic request tracing with `@hono/otel` middleware
- Instrument database operations with PostgreSQL auto-instrumentation
- Implement custom spans for business logic with `startActiveSpan`
- Trace background jobs with BullMQ and propagate context to workers
- Bridge Pino structured logging with OpenTelemetry trace correlation
- Configure Prometheus metrics alongside OpenTelemetry
- Validate request input with Zod via `@hono/zod-validator`
- Export telemetry data to base14 Scout via OTLP HTTP
- Deploy instrumented applications with Docker and Docker Compose
### Who This Guide Is For
This documentation is designed for:
- **Hono developers**: implementing observability and distributed tracing for
the first time in Node.js applications
- **DevOps engineers**: deploying Hono applications with production monitoring
requirements and container orchestration
- **Engineering teams**: migrating from DataDog, New Relic, or other commercial
APM solutions to open-source observability
- **Backend developers**: debugging performance issues, slow queries, or async
operation bottlenecks in Hono services
- **Platform teams**: standardizing observability across multiple Hono
microservices with consistent instrumentation patterns
### Prerequisites
Before starting, ensure you have:
- **Node.js 24.0.0 or later** installed (latest LTS recommended)
- **Hono 4.0.0 or later** installed in your project
- **Scout Collector** configured and accessible from your application
- See [Docker Compose Setup](../../collector-setup/docker-compose-example.md)
for local development
- See [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md)
for production deployment
- **Basic understanding** of OpenTelemetry concepts (traces, spans, attributes)
- Access to npm for package installation
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version | Notes |
| ------------------------- | --------------- | ------------------- | ----------------------------- |
| **Node.js** | 24.0.0 | 24.x LTS | Latest LTS with ESM support |
| **Hono** | 4.0.0 | 4.11.9+ | Latest v4 with middleware |
| **TypeScript** (optional) | 5.0.0 | 5.9.3+ | For type safety |
| **OpenTelemetry SDK** | 0.200.0 | 0.212+ | Core SDK for traces/metrics |
| **PostgreSQL** (optional) | 15.0 | 18.x | For database instrumentation |
| **Redis** (optional) | 7.0 | 8.x | For IORedis instrumentation |
| **Drizzle ORM** (optional)| 0.40.0 | 0.45.1+ | Type-safe SQL builder |
#### Supported Libraries
OpenTelemetry automatically instruments these commonly used libraries:
- **Web frameworks**: Hono (via @hono/otel), HTTP/HTTPS
- **Databases**: PostgreSQL (pg), MySQL, SQLite
- **Caching**: Redis (IORedis), Memcached
- **Job Queues**: BullMQ
- **HTTP Clients**: fetch, axios, http/https
- **Logging**: Pino (with trace correlation)
### Installation
#### Core Packages
Install the required OpenTelemetry packages for Hono instrumentation:
```bash showLineNumbers
npm install @opentelemetry/api
npm install @opentelemetry/sdk-node
npm install @opentelemetry/auto-instrumentations-node
npm install @opentelemetry/exporter-trace-otlp-http
npm install @opentelemetry/exporter-metrics-otlp-http
npm install @opentelemetry/resources
npm install @opentelemetry/semantic-conventions
```
#### Hono-Specific Packages
```bash showLineNumbers
npm install @hono/otel
npm install @hono/node-server
npm install @hono/zod-validator
```
#### Optional Instrumentation Libraries
```bash showLineNumbers
# Logs export
npm install @opentelemetry/api-logs
npm install @opentelemetry/sdk-logs
npm install @opentelemetry/exporter-logs-otlp-http
# Pino trace correlation
npm install pino pino-opentelemetry-transport
# Prometheus metrics
npm install prom-client
```
### Configuration
Choose the initialization method that best fits your application architecture:
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
#### SDK Configuration File (Recommended)
Create a `telemetry.ts` file that initializes OpenTelemetry before any other
imports. This is the recommended approach for Hono applications.
```typescript showLineNumbers title="src/telemetry.ts"
/**
* OpenTelemetry instrumentation setup for Hono application.
*
* CRITICAL: This file MUST be imported before any other modules
* to ensure auto-instrumentation captures all dependencies.
*/
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions';
const otlpEndpoint =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318';
const serviceName = process.env.OTEL_SERVICE_NAME || 'hono-postgres-app';
const serviceVersion = process.env.OTEL_SERVICE_VERSION || '1.0.0';
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: serviceName,
[ATTR_SERVICE_VERSION]: serviceVersion,
});
const sdk = new NodeSDK({
resource,
traceExporter: new OTLPTraceExporter({
url: `${otlpEndpoint}/v1/traces`,
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: `${otlpEndpoint}/v1/metrics`,
}),
exportIntervalMillis: 60000,
}),
logRecordProcessor: new BatchLogRecordProcessor(
new OTLPLogExporter({
url: `${otlpEndpoint}/v1/logs`,
})
),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
const url = req.url || '';
return url === '/health' || url === '/metrics';
},
},
'@opentelemetry/instrumentation-fs': { enabled: false },
'@opentelemetry/instrumentation-net': { enabled: false },
'@opentelemetry/instrumentation-dns': { enabled: false },
'@opentelemetry/instrumentation-pg': { requireParentSpan: true },
}),
],
});
sdk.start();
process.on('SIGTERM', () => {
sdk
.shutdown()
.then(() => console.log('OpenTelemetry SDK shut down'))
.catch((err) => console.error('Error shutting down SDK', err))
.finally(() => process.exit(0));
});
export { sdk };
```
Import this file as the first line in your application entry point:
```typescript showLineNumbers title="src/index.ts"
import './telemetry.js';
import { serve } from '@hono/node-server';
import { app } from './app.js';
import { config } from './config/index.js';
const start = async () => {
serve({
fetch: app.fetch,
port: config.port,
hostname: config.host,
});
console.log(`Server running at http://${config.host}:${config.port}`);
};
start();
```
```mdx-code-block
```
#### Environment Variables Only
For simpler setups or container deployments:
```bash showLineNumbers title=".env"
OTEL_SERVICE_NAME=hono-postgres-app
OTEL_SERVICE_VERSION=1.0.0
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
```
Then use a minimal telemetry file:
```typescript showLineNumbers title="src/telemetry.ts"
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
const sdk = new NodeSDK({
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
```
```mdx-code-block
```
#### Scout Collector Integration
Configure for base14 Scout with OAuth2 authentication:
```yaml showLineNumbers title="config/otel-config.yaml"
extensions:
oauth2client:
client_id: ${env:SCOUT_CLIENT_ID}
client_secret: ${env:SCOUT_CLIENT_SECRET}
token_url: ${env:SCOUT_TOKEN_URL}
endpoint_params:
audience: b14collector
timeout: 10s
health_check:
endpoint: 0.0.0.0:13133
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
limit_mib: 256
check_interval: 1s
batch:
timeout: 10s
send_batch_size: 1024
resource:
attributes:
- key: deployment.environment
value: ${env:SCOUT_ENVIRONMENT}
action: upsert
- key: environment
value: ${env:SCOUT_ENVIRONMENT}
action: upsert
exporters:
otlp_http/b14:
endpoint: ${env:SCOUT_ENDPOINT}
auth:
authenticator: oauth2client
compression: gzip
timeout: 30s
retry_on_failure:
enabled: true
initial_interval: 1s
max_interval: 30s
max_elapsed_time: 300s
debug:
verbosity: detailed
service:
extensions: [oauth2client, health_check]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [otlp_http/b14, debug]
metrics:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [otlp_http/b14, debug]
logs:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [otlp_http/b14, debug]
```
```mdx-code-block
```
### Production Configuration
#### Docker Deployment
```dockerfile showLineNumbers title="Dockerfile"
# Stage 1: Dependencies
FROM node:24.13.1-alpine3.23 AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
# Stage 2: Builder
FROM node:24.13.1-alpine3.23 AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Stage 3: Runtime
FROM node:24.13.1-alpine3.23 AS runtime
WORKDIR /app
RUN apk add --no-cache curl gcompat
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
COPY --from=builder /app/drizzle ./drizzle
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 hono && \
chown -R hono:nodejs /app
USER hono
EXPOSE 3000
CMD ["node", "dist/index.js"]
```
#### Docker Compose Configuration
```yaml showLineNumbers title="compose.yml"
services:
db-migrate:
build:
context: .
target: runtime
command: ["node", "dist/db/migrate.js"]
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/hono_app
depends_on:
postgres:
condition: service_healthy
networks:
- app-network
app:
build:
context: .
target: runtime
ports:
- "3000:3000"
environment:
NODE_ENV: development
PORT: "3000"
JWT_SECRET: dev-secret-key-change-in-production-must-be-32-chars
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/hono_app
REDIS_URL: redis://redis:6379
OTEL_SERVICE_NAME: hono-postgres-app
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
depends_on:
db-migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
otel-collector:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
- app-network
worker:
build:
context: .
target: runtime
command: ["node", "dist/jobs/worker.js"]
environment:
NODE_ENV: development
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/hono_app
REDIS_URL: redis://redis:6379
OTEL_SERVICE_NAME: hono-postgres-worker
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
depends_on:
db-migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
networks:
- app-network
postgres:
image: postgres:18.2-alpine3.23
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: hono_app
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
networks:
- app-network
redis:
image: redis:8.6.0-alpine3.23
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
networks:
- app-network
otel-collector:
image: otel/opentelemetry-collector-contrib:0.144.0
command: ["--config=/etc/otelcol-config.yaml"]
volumes:
- ./config/otel-config.yaml:/etc/otelcol-config.yaml:ro
ports:
- "4317:4317"
- "4318:4318"
- "13133:13133"
env_file:
- path: .env
required: false
environment:
SCOUT_ENDPOINT: ${SCOUT_ENDPOINT:-http://localhost:4318}
SCOUT_CLIENT_ID: ${SCOUT_CLIENT_ID:-}
SCOUT_CLIENT_SECRET: ${SCOUT_CLIENT_SECRET:-}
SCOUT_TOKEN_URL: ${SCOUT_TOKEN_URL:-}
SCOUT_ENVIRONMENT: ${SCOUT_ENVIRONMENT:-development}
networks:
- app-network
networks:
app-network:
driver: bridge
```
### Framework-Specific Instrumentation
#### Hono Middleware and Plugins
Hono's middleware system integrates with OpenTelemetry via `@hono/otel`:
```typescript showLineNumbers title="src/app.ts"
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { secureHeaders } from 'hono/secure-headers';
import { httpInstrumentationMiddleware } from '@hono/otel';
import { trace } from '@opentelemetry/api';
import client from 'prom-client';
import type { Variables } from './types/index.js';
const app = new Hono<{ Variables: Variables }>();
// Prometheus metrics
const register = new client.Registry();
register.setDefaultLabels({ app: 'hono-postgres' });
client.collectDefaultMetrics({ register });
const httpRequestsTotal = new client.Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'route', 'status_code'] as const,
registers: [register],
});
const httpRequestDuration = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'route', 'status_code'] as const,
buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [register],
});
// Global middleware
app.use('*', httpInstrumentationMiddleware());
app.use('*', secureHeaders());
app.use('*', cors({ origin: '*' }));
// Metrics collection middleware
app.use('*', async (c, next) => {
const start = performance.now();
await next();
const duration = (performance.now() - start) / 1000;
const route = c.req.routePath || c.req.path;
httpRequestsTotal.inc({
method: c.req.method,
route,
status_code: c.res.status.toString(),
});
httpRequestDuration.observe(
{ method: c.req.method, route, status_code: c.res.status.toString() },
duration
);
});
// Prometheus metrics endpoint
app.get('/metrics', async (c) => {
const metrics = await register.metrics();
return c.text(metrics, 200, { 'Content-Type': register.contentType });
});
// Global error handler with trace ID
app.onError((err, c) => {
const span = trace.getActiveSpan();
const traceId = span?.spanContext()?.traceId;
return c.json(
{
error: err.message,
statusCode: 500,
...(traceId && { traceId }),
},
500
);
});
export { app, register };
```
#### Pino Logger with OpenTelemetry Bridge
Structured logging with automatic trace correlation:
```typescript showLineNumbers title="src/services/logger.ts"
import pino, { Logger, LoggerOptions } from 'pino';
import { trace } from '@opentelemetry/api';
import { logs as otelLogs, SeverityNumber } from '@opentelemetry/api-logs';
const isDevelopment = process.env.NODE_ENV === 'development';
function createLoggerOptions(name: string): LoggerOptions {
return {
level: process.env.LOG_LEVEL || 'info',
name,
formatters: {
log(object: Record) {
const span = trace.getActiveSpan();
if (span) {
const { traceId, spanId } = span.spanContext();
return { ...object, traceId, spanId };
}
return object;
},
},
hooks: {
logMethod(inputArgs, method, level) {
const levelLabel = pino.levels.labels[level] || 'info';
const [objOrMsg, ...rest] = inputArgs;
let msg = '';
let obj: Record = {};
if (typeof objOrMsg === 'string') {
msg = objOrMsg;
} else if (typeof objOrMsg === 'object' && objOrMsg !== null) {
obj = objOrMsg as Record;
msg = rest[0] as string || '';
}
if (levelLabel === 'warn' || levelLabel === 'error' || levelLabel === 'fatal') {
const logger = otelLogs.getLogger('pino-otel-bridge');
const span = trace.getActiveSpan();
const spanContext = span?.spanContext();
const severityMap: Record = {
warn: SeverityNumber.WARN,
error: SeverityNumber.ERROR,
fatal: SeverityNumber.FATAL,
};
logger.emit({
severityNumber: severityMap[levelLabel] || SeverityNumber.INFO,
severityText: levelLabel.toUpperCase(),
body: msg,
attributes: {
...obj,
...(spanContext && {
'trace.id': spanContext.traceId,
'span.id': spanContext.spanId,
}),
},
});
}
method.apply(this, inputArgs);
},
},
transport: isDevelopment ? { target: 'pino-pretty' } : undefined,
};
}
export function createLogger(name: string): Logger {
return pino(createLoggerOptions(name));
}
```
#### Drizzle ORM with PostgreSQL
Database queries are automatically instrumented through the `pg` driver:
```typescript showLineNumbers title="src/db/schema.ts"
import {
pgTable, varchar, text, timestamp, integer, uniqueIndex, index,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const users = pgTable('users', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
email: varchar('email', { length: 255 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
bio: text('bio'),
image: varchar('image', { length: 500 }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [uniqueIndex('users_email_idx').on(table.email)]);
export const articles = pgTable('articles', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
slug: varchar('slug', { length: 255 }).notNull().unique(),
title: varchar('title', { length: 255 }).notNull(),
description: text('description'),
body: text('body').notNull(),
authorId: integer('author_id').notNull()
.references(() => users.id, { onDelete: 'cascade' }),
favoritesCount: integer('favorites_count').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
uniqueIndex('articles_slug_idx').on(table.slug),
index('articles_author_id_idx').on(table.authorId),
]);
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Article = typeof articles.$inferSelect;
export type NewArticle = typeof articles.$inferInsert;
```
```typescript showLineNumbers title="src/db/index.ts"
import { drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import * as schema from './schema.js';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
export const db = drizzle(pool, { schema });
```
#### Zod Request Validation
Use `@hono/zod-validator` for type-safe request validation:
```typescript showLineNumbers title="src/validators/article.ts"
import { z } from 'zod';
export const createArticleSchema = z.object({
title: z.string().min(1, 'Title is required').max(255),
description: z.string().max(1000).optional(),
body: z.string().min(1, 'Body is required'),
});
export const updateArticleSchema = z.object({
title: z.string().min(1).max(255).optional(),
description: z.string().max(1000).optional(),
body: z.string().min(1).optional(),
});
```
```typescript showLineNumbers title="src/routes/articles.ts"
import { zValidator } from '@hono/zod-validator';
import { createArticleSchema } from '../validators/article.js';
articlesRouter.post(
'/',
authenticate,
zValidator('json', createArticleSchema),
async (c) => {
const data = c.req.valid('json');
const { id: userId } = c.get('user');
const article = await createArticle(userId, data);
return c.json({ article }, 201);
}
);
```
### Custom Instrumentation
#### Business Logic Spans with startActiveSpan
Add custom spans for business-critical operations:
```typescript showLineNumbers title="src/services/article.ts"
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { db } from '../db/index.js';
import { articles, users } from '../db/schema.js';
import { enqueueArticleCreatedNotification } from '../jobs/tasks/notification.js';
const tracer = trace.getTracer('article-service');
export async function createArticle(
authorId: number,
input: { title: string; description?: string; body: string }
) {
return tracer.startActiveSpan('article.create', async (span) => {
try {
span.setAttribute('user.id', authorId);
const slug = generateSlug(input.title);
const [newArticle] = await db
.insert(articles)
.values({
slug,
title: input.title,
description: input.description || null,
body: input.body,
authorId,
})
.returning();
span.setAttribute('article.id', newArticle.id);
span.setAttribute('article.slug', newArticle.slug);
span.setStatus({ code: SpanStatusCode.OK });
enqueueArticleCreatedNotification({
articleId: newArticle.id,
articleSlug: newArticle.slug,
authorId,
authorName: '',
title: newArticle.title,
}).catch((err) => {
console.error('Failed to enqueue notification', err);
});
return newArticle;
} catch (error) {
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: (error as Error).message,
});
throw error;
} finally {
span.end();
}
});
}
```
#### Background Job Tracing with BullMQ
##### Producer: Inject Trace Context
```typescript showLineNumbers title="src/jobs/tasks/notification.ts"
import { context, propagation, trace, SpanKind } from '@opentelemetry/api';
import { notificationQueue } from '../queue.js';
const tracer = trace.getTracer('notification-tasks');
function getTraceContext(): Record {
const traceContext: Record = {};
propagation.inject(context.active(), traceContext);
return traceContext;
}
export async function enqueueArticleCreatedNotification(
payload: ArticleCreatedPayload
): Promise {
return tracer.startActiveSpan(
'job.enqueue.article-created',
{ kind: SpanKind.PRODUCER },
async (span) => {
try {
span.setAttribute('article.id', payload.articleId);
span.setAttribute('messaging.system', 'bullmq');
span.setAttribute('messaging.destination.name', 'notifications');
span.setAttribute('messaging.operation.type', 'publish');
const job = await notificationQueue.add('article-created', {
...payload,
traceContext: getTraceContext(),
});
span.setAttribute('job.id', job.id || 'unknown');
span.addEvent('job_enqueued');
} finally {
span.end();
}
}
);
}
```
##### Consumer: Extract and Restore Trace Context
```typescript showLineNumbers title="src/jobs/worker.ts"
import '../telemetry.js';
import { Worker, Job } from 'bullmq';
import {
trace, context, propagation, SpanStatusCode, SpanKind,
} from '@opentelemetry/api';
const tracer = trace.getTracer('notification-worker');
const worker = new Worker(
'notifications',
async (job) => {
const parentContext = job.data.traceContext
? propagation.extract(context.active(), job.data.traceContext)
: context.active();
return context.with(parentContext, async () => {
return tracer.startActiveSpan(
`job.${job.name}`,
{
kind: SpanKind.CONSUMER,
attributes: {
'job.id': job.id || 'unknown',
'job.name': job.name,
'job.queue': 'notifications',
'job.attempt': job.attemptsMade + 1,
'messaging.system': 'bullmq',
'messaging.destination.name': 'notifications',
'messaging.operation.type': 'process',
},
},
async (span) => {
try {
switch (job.name) {
case 'article-created':
await processArticleCreated(job);
break;
default:
console.warn(`Unknown job type: ${job.name}`);
}
span.setStatus({ code: SpanStatusCode.OK });
} catch (error) {
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: (error as Error).message,
});
throw error;
} finally {
span.end();
}
}
);
});
},
{ connection: { host: 'localhost', port: 6379 }, concurrency: 5 }
);
```
### Running Your Application
```mdx-code-block
```
```bash showLineNumbers
npm run dev
# In a separate terminal
npm run dev:worker
```
```mdx-code-block
```
```bash showLineNumbers
npm run build
NODE_ENV=production \
OTEL_SERVICE_NAME=hono-postgres-app \
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 \
node dist/index.js
```
```mdx-code-block
```
```bash showLineNumbers
docker compose up --build
docker compose logs -f app worker
docker compose down
```
```mdx-code-block
```
### Troubleshooting
#### Health Check Endpoint
```typescript showLineNumbers title="src/routes/health.ts"
import { Hono } from 'hono';
import { trace } from '@opentelemetry/api';
import { checkDatabaseHealth } from '../db/index.js';
const healthRouter = new Hono();
healthRouter.get('/', async (c) => {
const span = trace.getActiveSpan();
const traceId = span?.spanContext()?.traceId;
const dbHealthy = await checkDatabaseHealth();
return c.json({
status: dbHealthy ? 'healthy' : 'unhealthy',
database: dbHealthy ? 'connected' : 'disconnected',
traceId,
timestamp: new Date().toISOString(),
});
});
export default healthRouter;
```
#### Debug Mode
```bash showLineNumbers
OTEL_LOG_LEVEL=debug npm run dev
```
#### Common Issues
##### Issue: No traces appearing in Scout
**Solutions:**
1. Verify collector connectivity:
```bash showLineNumbers
curl -f http://localhost:4318/v1/traces
```
2. Ensure `telemetry.ts` is imported first in `index.ts`
3. Check environment variables are set correctly
4. Verify Scout credentials in collector config
##### Issue: Missing database spans
**Solutions:**
1. Ensure `@opentelemetry/auto-instrumentations-node` is installed
2. Verify the `pg` driver is being used (not `pg-native`)
3. Check that telemetry initialization happens before database import
##### Issue: Background job traces not linked
**Solutions:**
1. Verify trace context is propagated to job data with `propagation.inject`
2. Ensure worker imports `telemetry.ts` before other modules
3. Check that `context.with()` wraps the job processor
##### Issue: High memory usage
**Solutions:**
1. Reduce batch size in exporter configuration
2. Disable filesystem instrumentation (already disabled in recommended config)
### Security Considerations
#### Sensitive Data Protection
```typescript showLineNumbers
// BAD: Captures password
span.setAttribute('user.password', password);
// GOOD: Only capture non-sensitive identifiers
span.setAttribute('user.id', userId);
span.setAttribute('user.email_domain', email.split('@')[1]);
```
#### SQL Query Obfuscation
```typescript showLineNumbers title="src/telemetry.ts"
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-pg': {
enhancedDatabaseReporting: false,
},
}),
],
```
#### Compliance Considerations
For GDPR, HIPAA, or PCI-DSS compliance:
- Never log PII in span attributes
- Use pseudonymization for user identifiers when possible
- Configure data retention policies in your observability backend
- Implement attribute filtering at the collector level
### Performance Considerations
#### Expected Impact
| Metric | Typical Impact | High-Traffic Impact |
| ------------ | -------------- | ------------------- |
| Latency | +1-3ms | +2-5ms |
| CPU overhead | 2-5% | 5-10% |
| Memory | +50-100MB | +100-200MB |
#### Optimization Best Practices
##### 1. Skip Non-Critical Endpoints
```typescript showLineNumbers
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
const url = req.url || '';
return url === '/health' || url === '/metrics' || url === '/favicon.ico';
},
},
```
##### 2. Disable Unnecessary Instrumentations
```typescript showLineNumbers
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false },
'@opentelemetry/instrumentation-dns': { enabled: false },
'@opentelemetry/instrumentation-net': { enabled: false },
}),
],
```
### FAQ
#### What is the performance impact of OpenTelemetry on Hono?
OpenTelemetry typically adds 1-3ms latency per request with 2-5% CPU overhead.
The `BatchSpanProcessor` minimizes impact by buffering spans for batch export.
#### Which versions of Hono are supported?
This guide focuses on Hono 4.x with `@hono/otel` middleware. The same
auto-instrumentation approach works with any Hono version since HTTP-level
instrumentation is framework-agnostic.
#### How does Hono OpenTelemetry middleware compare to Fastify?
`@hono/otel` is a Hono middleware that creates spans with route-parameterized
names. Fastify uses hooks and plugins. Both achieve the same result —
automatic HTTP span generation with method, route, and status code.
#### How do I instrument Hono with PostgreSQL and Drizzle ORM?
PostgreSQL queries are automatically instrumented through the `pg` driver.
Drizzle ORM uses `pg` under the hood, so all queries appear as database spans
without additional configuration.
#### How do I trace background jobs with BullMQ?
Inject trace context when enqueuing with `propagation.inject()` and extract
it in the worker with `propagation.extract()`. Wrap the worker processor in
`context.with(parentContext, ...)` to link consumer spans to the producer.
#### How do I correlate Pino logs with traces?
The custom `createLogger` function adds `traceId` and `spanId` to every log
entry automatically. Warn, error, and fatal logs are also emitted to the OTel
log provider for export alongside traces.
#### Can I use Prometheus metrics alongside OpenTelemetry?
Yes. The example uses `prom-client` for Prometheus-compatible metrics exposed
at `/metrics`, alongside OpenTelemetry metrics exported via OTLP. Both can
coexist.
#### How do I handle multi-tenancy in traces?
Set a `tenant.id` attribute on the active span from the tenant header, then
filter by it in Scout:
```typescript showLineNumbers
span.setAttribute('tenant.id', request.headers['x-tenant-id']);
```
#### How do I reduce trace volume in production?
Use `ignoreIncomingRequestHook` to skip health checks and static assets,
and disable unnecessary instrumentations like filesystem and DNS.
#### What's the difference between OTLP HTTP and gRPC?
This guide uses OTLP HTTP (port 4318) which works through HTTP proxies and
load balancers. OTLP gRPC (port 4317) offers slightly better performance but
requires HTTP/2 support. Both are fully supported by Scout Collector.
### What's Next?
#### Related Guides
- [Fastify Instrumentation](./fastify.md) - High-performance Node.js framework
- [Node.js Custom Instrumentation](../custom-instrumentation/javascript-node.md)
\- Manual spans and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
#### Scout Platform Features
- [Creating Alerts](../../../guides/creating-alerts-with-logx.md) - Set up
alerting for Hono services
- [Dashboard Creation](../../../guides/create-your-first-dashboard.md) - Build
custom dashboards
#### Deployment and Operations
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) -
Local development configuration
- [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md) -
Production deployment
### Complete Example
#### Project Structure
```text
hono-postgres/
├── src/
│ ├── telemetry.ts # OTel SDK initialization (import first!)
│ ├── index.ts # Application entry point
│ ├── app.ts # Hono app with @hono/otel middleware
│ ├── config/
│ │ └── index.ts # Environment configuration
│ ├── db/
│ │ ├── schema.ts # Drizzle schema (users, articles, favorites)
│ │ ├── index.ts # Database connection pool
│ │ └── migrate.ts # Drizzle migration runner
│ ├── services/
│ │ ├── logger.ts # Pino + OTel bridge
│ │ ├── article.ts # Article CRUD with custom spans
│ │ └── user.ts # Auth operations with custom spans
│ ├── routes/
│ │ ├── health.ts # Health check endpoints
│ │ ├── auth.ts # Authentication routes
│ │ └── articles.ts # Article CRUD routes
│ ├── middleware/
│ │ └── auth.ts # JWT authentication
│ ├── validators/
│ │ ├── user.ts # Zod schemas for auth
│ │ └── article.ts # Zod schemas for articles
│ └── jobs/
│ ├── queue.ts # BullMQ queue setup
│ ├── worker.ts # Background worker (CONSUMER)
│ └── tasks/
│ └── notification.ts # Job producers (PRODUCER)
├── config/
│ └── otel-config.yaml # Collector configuration
├── compose.yml
├── Dockerfile
├── package.json
└── tsconfig.json
```
#### Dependencies
```json showLineNumbers title="package.json"
{
"name": "hono-postgres",
"version": "1.0.0",
"type": "module",
"engines": { "node": ">=24.0.0" },
"dependencies": {
"@hono/node-server": "^1.19.9",
"@hono/otel": "^1.1.0",
"@hono/zod-validator": "^0.7.6",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.212.0",
"@opentelemetry/auto-instrumentations-node": "^0.69.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.212.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.212.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.212.0",
"@opentelemetry/resources": "^2.5.1",
"@opentelemetry/sdk-logs": "^0.212.0",
"@opentelemetry/sdk-metrics": "^2.5.1",
"@opentelemetry/sdk-node": "^0.212.0",
"@opentelemetry/semantic-conventions": "^1.39.0",
"bullmq": "^5.69.1",
"drizzle-orm": "^0.45.1",
"hono": "^4.11.9",
"ioredis": "^5.9.3",
"pg": "^8.18.0",
"pino": "^10.3.1",
"prom-client": "^15.1.3",
"zod": "^4.3.6"
}
}
```
#### GitHub Repository
For a complete working example, see the
[Hono PostgreSQL Example](https://github.com/base-14/examples/tree/main/nodejs/hono-postgres)
repository.
### References
- [Official OpenTelemetry Node.js Documentation](https://opentelemetry.io/docs/languages/js/)
- [Hono Documentation](https://hono.dev/docs/)
- [@hono/otel Middleware](https://www.npmjs.com/package/@hono/otel)
- [BullMQ Documentation](https://docs.bullmq.io/)
- [Drizzle ORM Documentation](https://orm.drizzle.team/)
### Related Guides
- [Fastify Instrumentation](./fastify.md) - Similar Node.js web framework
- [Express.js Instrumentation](./express.md) - Classic Node.js framework
- [NestJS Instrumentation](./nestjs.md) - TypeScript-first framework
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) -
Local collector configuration
---
## Auto-Instrumentation Overview - Zero-Code OpenTelemetry Setup
## Auto-Instrumentation
Auto-instrumentation provides **zero-code observability** by automatically
capturing traces, metrics, and logs from your application and its dependencies.
This is the fastest way to get started with OpenTelemetry. If you want to verify
your collector is working first, try the
[Hello World guide](/instrument/apps/hello-world).
### When to Use Auto-Instrumentation
| Use Case | Recommendation |
| --------------------------------- | ----------------------------------------------------------- |
| Quick proof-of-concept | ✅ Auto-instrumentation |
| Standard HTTP/database operations | ✅ Auto-instrumentation |
| Business-specific metrics | ❌ Use [custom instrumentation](../custom-instrumentation/) |
| Fine-grained span control | ❌ Use [custom instrumentation](../custom-instrumentation/) |
| Legacy framework not supported | ❌ Use [custom instrumentation](../custom-instrumentation/) |
### Frameworks by Language
#### Python
| Framework | Guide | What's Instrumented |
| ---------- | -------------------------- | -------------------------------------------------------------------------------- |
| Django | [Django](./django) | HTTP requests, ORM queries, middleware, templates, Celery tasks |
| Flask | [Flask](./flask) | HTTP requests, Jinja2 templates, SQLAlchemy |
| FastAPI | [FastAPI](./fast-api) | HTTP requests, async handlers, Pydantic validation |
| Litestar | [Litestar](./litestar) | HTTP requests, asyncpg, SQLAlchemy, httpx, msgspec validation |
| Celery | [Celery](./celery) | Task execution, retries, worker lifecycle |
| LangChain | [LangChain](./langchain) | Agent runs, LLM calls, tool execution, RAG retrieval, token/cost tracking |
| LangGraph | [LangGraph](./langgraph) | Agent pipelines, LLM calls, tool nodes, conditional routing, token/cost tracking |
| LlamaIndex | [LlamaIndex](./llamaindex) | LLM calls, structured output, token/cost tracking, quality evaluation |
#### Node.js
| Framework | Guide | What's Instrumented |
| ----------------- | -------------------- | ------------------------------------------------- |
| Express | [Express](./express) | HTTP requests, middleware, routing |
| Fastify | [Fastify](./fastify) | HTTP requests, hooks, plugins, PostgreSQL, BullMQ |
| Hono | [Hono](./hono) | HTTP requests, middleware, PostgreSQL, BullMQ |
| Elysia (Bun) | [Elysia](./elysia) | HTTP requests, middleware, structured logging |
| tRPC | [tRPC](./trpc) | Procedures, Prisma queries, PostgreSQL |
| NestJS | [NestJS](./nestjs) | Controllers, services, guards, interceptors |
| Next.js | [Next.js](./nextjs-scout) | SSR, route handlers, outbound fetch — direct OTLP to Scout, no collector |
| Next.js (full-stack) | [Next.js Full-Stack](./nextjs-fullstack) | Browser + server traces, error boundaries, Web Vitals, OTLP proxy route |
| Next.js (collector) | [Next.js Collector](./nextjs) | SSR, API routes, middleware, React components — exports to your collector |
| Node.js (generic) | [Node.js](./nodejs) | HTTP, filesystem, child processes |
| Vercel AI SDK | [Vercel AI SDK](./vercel-ai-sdk) | LLM calls, AI pipelines, token/cost tracking |
#### Frontend / Browser
| Framework | Guide | What's Instrumented |
| --------- | -------------------- | -------------------------------------------------------------- |
| Angular | [Angular](./angular) | Browser traces, Core Web Vitals metrics, error logs (zoneless) |
| React | [React](./react) | Routes, clicks, fetch/XHR, Core Web Vitals, error logs |
#### Java / JVM
| Framework | Guide | What's Instrumented |
| ----------------- | ------------------------------------------ | -------------------------------------- |
| Spring Boot | [Spring Boot](./spring-boot) | REST controllers, JPA, JDBC, messaging |
| Spring Boot (alt) | [Alternatives](./spring-boot-alternatives) | Micrometer, manual agent setup |
| Quarkus | [Quarkus](./quarkus) | REST endpoints, Hibernate, Kafka |
| Micronaut | [Micronaut](./micronaut) | HTTP endpoints, Hibernate JPA, logs |
| Ktor (Kotlin) | [Ktor](./ktor) | HTTP routing, Exposed ORM, logs |
#### Ruby
| Framework | Guide | What's Instrumented |
| ------------------------ | ------------------------------ | ----------------------------------------------- |
| Rails 6+ (Ruby 3.1+) | [Rails](./rails) | Controllers, ActiveRecord, ActionCable, Sidekiq |
| Rails 5.x–6.1 (EOL Ruby) | [Rails Legacy](./rails-legacy) | Controllers, ActiveRecord (pinned SDK versions) |
#### Go
| Framework | Guide | What's Instrumented |
| -------------------- | -------------------------------------------------- | ------------------------------------------------ |
| Go (net/http) | [Go](./go) | HTTP handlers, database/sql, gRPC |
| Go stdlib + Postgres | [Go stdlib + Postgres](./go-stdlib-postgres) | net/http server + client, pgx queries, slog logs |
#### Rust
| Framework | Guide | What's Instrumented |
| --------- | ------------------------ | ------------------------------------------- |
| Actix Web | [Actix Web](./actix-web) | HTTP requests, middleware, database queries |
| Axum | [Axum](./axum) | Handlers, middleware, tower layers |
#### PHP
| Framework | Guide | What's Instrumented |
| --------------- | ------------------------ | --------------------------------------------------------------- |
| Laravel | [Laravel](./laravel) | HTTP requests, Eloquent, queues, caching |
| Slim 4 / Slim 3 | [Slim](./slim) | HTTP requests, MongoDB, metrics, log correlation |
| Symfony | [Symfony](./symfony) | HTTP requests, Doctrine ORM, HTTP client, logs |
| WordPress | [WordPress](./wordpress) | HTTP requests, WP lifecycle, template selection, mysqli queries |
#### .NET
| Framework | Guide | What's Instrumented |
| ------------ | ------------------------------ | ----------------------------------------- |
| ASP.NET Core | [.NET](./dotnet) | HTTP requests, EF Core, HttpClient |
| .NET Aspire | [.NET Aspire](./dotnet-aspire) | Multi-service tracing, HTTP, EF Core |
#### Elixir
| Framework | Guide | What's Instrumented |
| --------- | --------------------------- | ----------------------------------- |
| Phoenix | [Phoenix](./elixir-phoenix) | Controllers, Ecto, LiveView, PubSub |
#### Mobile / Cross-Platform
| Framework | Guide | What's Instrumented |
| --------- | ------------------------------- | ----------------------------------------------------------------- |
| Flutter | [Flutter](../../mobile/flutter) | HTTP requests, crash handling, app lifecycle, distributed tracing |
### How Auto-Instrumentation Works
import ThemedImage from '@theme/ThemedImage';
### Next Steps
1. **Choose your framework** from the tables above
2. **Follow the guide** to add auto-instrumentation
3. **Add [custom instrumentation](../custom-instrumentation/)** for
business-specific telemetry
---
## Ktor OpenTelemetry Instrumentation - Java Agent, Exposed ORM & Netty Tracing
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
Implement OpenTelemetry instrumentation for Kotlin Ktor applications using
the OpenTelemetry Java Agent for zero-code distributed tracing, Exposed ORM
query monitoring, and structured log correlation. The Java Agent attaches to
the JVM at startup and automatically instruments HTTP requests, JDBC queries,
Netty server operations, and outgoing HTTP client calls without any code
changes.
Ktor is JetBrains' Kotlin-first framework. On the JVM it sits alongside
[Spring Boot](./spring-boot.md) and the compile-time [Micronaut](./micronaut.md).
Ktor applications benefit from the Java Agent's comprehensive coverage of
the JVM ecosystem including JDBC (via Exposed ORM), HikariCP connection
pools, Java HTTP clients, and Netty. With OpenTelemetry, you can monitor
production performance, debug slow requests, trace distributed transactions
across microservices, and correlate logs with traces using a single
`-javaagent` flag.
Whether you're implementing observability for the first time, migrating from
commercial APM solutions, or troubleshooting performance issues in production,
this guide provides production-ready configurations for Ktor OpenTelemetry
instrumentation.
> **Note:** This guide provides a practical Ktor-focused overview based on
> the official OpenTelemetry documentation. For complete Java agent
> information, please consult the
> [official OpenTelemetry Java documentation](https://opentelemetry.io/docs/languages/java/).
:::tip TL;DR
Download the OpenTelemetry Java Agent JAR, set
`JAVA_TOOL_OPTIONS="-javaagent:/path/to/opentelemetry-javaagent.jar"`, and
configure `OTEL_SERVICE_NAME` + `OTEL_EXPORTER_OTLP_ENDPOINT`. HTTP
requests, Exposed/JDBC queries, Netty I/O, and HTTP client calls are traced
automatically with zero code changes. The agent injects `trace_id` and
`span_id` into SLF4J MDC for log correlation. Works seamlessly with Kotlin
coroutines.
:::
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Who This Guide Is For
This documentation is designed for:
- **Kotlin developers**: building Ktor APIs and implementing observability
for the first time
- **Cloud-native teams**: running Ktor microservices in Kubernetes or Docker
- **DevOps engineers**: deploying Kotlin/JVM applications with production
monitoring
- **Engineering teams**: migrating from Datadog, New Relic, or other
commercial APM solutions
- **Platform teams**: standardizing observability across JVM services
(Ktor, Micronaut, Spring Boot)
### Overview
This guide demonstrates how to:
- Attach the OpenTelemetry Java Agent to Ktor applications for zero-code
instrumentation
- Configure trace export to Scout Collector via environment variables
- Set up structured JSON logging with automatic trace context correlation
- Wire custom metrics and spans using the OpenTelemetry API with Kotlin
idioms
- Deploy instrumented applications with Docker Compose (app + notify +
PostgreSQL + collector)
- Trace requests across multiple Ktor services (distributed tracing)
- Troubleshoot common instrumentation issues
### Prerequisites
Before starting, ensure you have:
- **Java 17 or later** (Java 21+ recommended for best performance)
- Eclipse Temurin or any OpenJDK distribution
- **Kotlin 2.0 or later** (Kotlin 2.2+ recommended)
- **Ktor 2.x or 3.x** installed
- Ktor 3.x is recommended for optimal compatibility
- **Gradle 8.x** for build management
- **Scout Collector** configured and accessible
- See
[Docker Compose Setup](../../collector-setup/docker-compose-example.md)
for local development
- Basic understanding of OpenTelemetry concepts (traces, spans, attributes)
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version |
| -------------------------- | --------------- | ------------------- |
| Java | 17 | 21+ |
| Kotlin | 1.9.0 | 2.2.0+ |
| Ktor | 2.0.0 | 3.2.0+ |
| Gradle | 8.0 | 8.10+ |
| OpenTelemetry Java Agent | 1.0.0 | 2.26+ |
| Exposed ORM | 0.40.0 | 0.61.0+ |
| PostgreSQL Driver | 42.5.0 | Latest stable |
#### Instrumented Components (Automatic)
The Java Agent instruments these components with zero code changes:
| Component | Coverage |
| --------------------- | ------------------------------------------------- |
| Ktor HTTP Server | Routes, handlers, request/response attributes |
| Netty | Server I/O, connection handling |
| Exposed / JDBC | All SQL queries, transactions, connection pools |
| Java HTTP Client | Outgoing HTTP calls, W3C trace propagation |
| HikariCP | Connection pool metrics |
| Logback | MDC injection of trace_id and span_id |
| Flyway | Database migration spans |
| Kotlin Coroutines | Trace context propagation across suspensions |
#### Example Application
This guide references the
[ktor-postgres](https://github.com/base-14/examples/tree/main/kotlin/ktor-postgres)
example: a Ktor 3.2 REST API with Exposed ORM, a notification microservice,
and full OpenTelemetry instrumentation.
### Installation
#### Step 1: Download the OpenTelemetry Java Agent
Download the latest agent JAR from the official releases:
```bash
curl -L -o opentelemetry-javaagent.jar \
https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.26.1/opentelemetry-javaagent.jar
```
#### Step 2: Attach the Agent to the JVM
The agent attaches via the `-javaagent` JVM flag. The simplest approach is
the `JAVA_TOOL_OPTIONS` environment variable:
```bash
export JAVA_TOOL_OPTIONS="-javaagent:/path/to/opentelemetry-javaagent.jar"
```
This works regardless of how you start your application (Gradle, java -jar,
etc.).
#### Step 3: Add the OpenTelemetry API Dependency (Optional)
For custom metrics and manual spans, add the OpenTelemetry API to your
build. This is optional if you only need automatic instrumentation:
```kotlin title="build.gradle.kts" showLineNumbers
plugins {
kotlin("jvm") version "2.2.0"
kotlin("plugin.serialization") version "2.2.0"
id("io.ktor.plugin") version "3.2.0"
id("com.gradleup.shadow") version "8.3.6"
}
dependencies {
// Ktor core
implementation("io.ktor:ktor-server-core:3.2.0")
implementation("io.ktor:ktor-server-netty:3.2.0")
implementation("io.ktor:ktor-server-content-negotiation:3.2.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.2.0")
implementation("io.ktor:ktor-server-status-pages:3.2.0")
// Database (Exposed ORM + PostgreSQL)
implementation("org.jetbrains.exposed:exposed-core:0.61.0")
implementation("org.jetbrains.exposed:exposed-dao:0.61.0")
implementation("org.jetbrains.exposed:exposed-jdbc:0.61.0")
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:0.61.0")
implementation("com.zaxxer:HikariCP:6.2.1")
implementation("org.postgresql:postgresql:42.7.7")
// Flyway migrations
implementation("org.flywaydb:flyway-core:12.2.0")
runtimeOnly("org.flywaydb:flyway-database-postgresql:12.2.0")
// Optional: OTel API for custom metrics and spans
implementation("io.opentelemetry:opentelemetry-api:1.48.0")
// Logging
implementation("ch.qos.logback:logback-classic:1.5.18")
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
}
```
The `opentelemetry-api` dependency is a compile-time-only API. The Java Agent
provides the implementation at runtime.
### Configuration
```mdx-code-block
```
Configure the agent entirely through environment variables:
```bash title=".env"
# OpenTelemetry Java Agent
OTEL_SERVICE_NAME=ktor-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
OTEL_METRIC_EXPORT_INTERVAL=10000
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development,environment=development
```
The agent reads these variables at startup. No application code or config
files need to change.
```mdx-code-block
```
Ktor uses programmatic configuration. Here's the application entry point
with database and service wiring:
```kotlin title="src/main/kotlin/com/example/Application.kt" showLineNumbers
package com.example
import com.example.plugins.configureRouting
import com.example.plugins.configureSerialization
import com.example.repository.ArticleRepository
import com.example.service.NotificationClient
import com.example.service.TelemetryService
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.sql.Database
fun main() {
embeddedServer(Netty, serverConfig {
module(Application::module)
}) {
connector { port = 8080 }
}.start(wait = true)
}
fun Application.module() {
val dataSource = HikariDataSource(HikariConfig().apply {
jdbcUrl = "jdbc:postgresql://${env("DB_HOST", "localhost")}:${env("DB_PORT", "5432")}/${env("DB_NAME", "ktor_articles")}"
username = env("DB_USER", "postgres")
password = env("DB_PASSWORD", "postgres")
maximumPoolSize = 10
})
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.load()
.migrate()
Database.connect(dataSource)
val articleRepository = ArticleRepository()
val notificationClient = NotificationClient(env("NOTIFY_URL", "http://localhost:8081"))
val telemetryService = TelemetryService()
configureSerialization()
configureRouting(articleRepository, notificationClient, telemetryService, dataSource)
}
private fun env(name: String, default: String): String =
System.getenv(name) ?: default
```
The Java Agent instruments HikariCP, JDBC, and Netty automatically
regardless of how you configure the application.
```mdx-code-block
```
Run the full observability stack locally:
```yaml title="compose.yml" showLineNumbers
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.148.0
command: ["--config=/etc/otel/config.yaml"]
volumes:
- ./config/otel-config.yaml:/etc/otel/config.yaml:ro
ports:
- "4317:4317"
- "4318:4318"
environment:
SCOUT_ENDPOINT: ${SCOUT_ENDPOINT:-http://localhost:4318}
SCOUT_CLIENT_ID: ${SCOUT_CLIENT_ID:-}
SCOUT_CLIENT_SECRET: ${SCOUT_CLIENT_SECRET:-}
SCOUT_TOKEN_URL: ${SCOUT_TOKEN_URL:-http://localhost/token}
restart: unless-stopped
db:
image: postgres:18-alpine
environment:
POSTGRES_DB: ktor_articles
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
app:
build: ./app
ports:
- "8080:8080"
environment:
DB_HOST: db
DB_PORT: "5432"
DB_NAME: ktor_articles
DB_USER: postgres
DB_PASSWORD: postgres
NOTIFY_URL: http://notify:8081
OTEL_SERVICE_NAME: ktor-articles
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
OTEL_METRIC_EXPORT_INTERVAL: "10000"
OTEL_LOGS_EXPORTER: otlp
depends_on:
db:
condition: service_healthy
otel-collector:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8080/api/health"]
interval: 10s
timeout: 5s
start_period: 30s
retries: 10
restart: unless-stopped
volumes:
pgdata:
```
```mdx-code-block
```
#### Configure Structured Logging
Set up Logback with JSON output. The Java Agent automatically injects
`trace_id` and `span_id` into the SLF4J MDC:
```xml title="src/main/resources/logback.xml" showLineNumbers
trace_id
span_id
```
Every log line includes `trace_id` and `span_id` automatically.
#### Scout Collector Integration
Configure trace export to Scout with OAuth2 authentication:
```bash title=".env"
SCOUT_ENDPOINT=https://your-tenant.base14.io/v1/traces
SCOUT_CLIENT_ID=your_client_id
SCOUT_CLIENT_SECRET=your_client_secret
SCOUT_TOKEN_URL=https://your-tenant.base14.io/oauth/token
OTEL_SERVICE_NAME=ktor-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
```
> **Scout Dashboard Integration**: After configuration, your traces will
> appear in the Scout Dashboard. Navigate to the Traces section to view
> request flows, identify bottlenecks, and analyze distributed transactions.
### Production Configuration
#### Production Environment Variables
```bash title=".env.production"
OTEL_SERVICE_NAME=ktor-app
OTEL_SERVICE_VERSION=2.1.3
OTEL_EXPORTER_OTLP_ENDPOINT=https://scout-collector.example.com/v1/traces
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_COMPRESSION=gzip
OTEL_EXPORTER_OTLP_TIMEOUT=10000
# Batch Span Processor (Production Optimized)
OTEL_BSP_MAX_QUEUE_SIZE=2048
OTEL_BSP_SCHEDULE_DELAY=5000
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
OTEL_METRIC_EXPORT_INTERVAL=30000
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=demo,environment=demo,host.name=${HOSTNAME}
```
#### Docker Production Configuration
Multi-stage Dockerfile that builds a shadow JAR and bakes in the
OpenTelemetry Java Agent:
```dockerfile title="Dockerfile" showLineNumbers
FROM eclipse-temurin:24-jdk AS builder
WORKDIR /app
COPY gradle/ gradle/
COPY gradlew settings.gradle.kts build.gradle.kts ./
RUN chmod +x gradlew && ./gradlew dependencies --no-daemon
COPY src/ src/
RUN ./gradlew shadowJar --no-daemon
FROM eclipse-temurin:24-jre
WORKDIR /app
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
ARG OTEL_AGENT_VERSION=2.26.1
ADD https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${OTEL_AGENT_VERSION}/opentelemetry-javaagent.jar /app/opentelemetry-javaagent.jar
RUN addgroup --gid 1001 appgroup && \
adduser --uid 1001 --gid 1001 --disabled-password --gecos "" appuser && \
chown appuser:appgroup /app/opentelemetry-javaagent.jar
COPY --from=builder --chown=appuser:appgroup /app/build/libs/app.jar /app/app.jar
USER appuser
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-javaagent:/app/opentelemetry-javaagent.jar"
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
```
Key details:
- **Multi-stage build** separates Gradle build from runtime image
- **Shadow JAR** bundles all Kotlin/Ktor dependencies into `app.jar`
- **OTel Java Agent** downloaded and baked into the image
- **`JAVA_TOOL_OPTIONS`** attaches the agent on every JVM start
- **Non-root user** (`appuser:1001`) for security
#### Multi-Service Distributed Tracing
For architectures with multiple services, each gets its own
`OTEL_SERVICE_NAME`. The Java Agent automatically propagates W3C
`traceparent` headers on outgoing HTTP requests.
Here's the notification client from the example app:
```kotlin title="src/main/kotlin/com/example/service/NotificationClient.kt" showLineNumbers
package com.example.service
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
class NotificationClient(private val notifyUrl: String) {
private val logger = LoggerFactory.getLogger(NotificationClient::class.java)
private val httpClient = HttpClient.newHttpClient()
suspend fun notify(payload: Map) {
withContext(Dispatchers.IO) {
val json = Json.encodeToString(payload)
val request = HttpRequest.newBuilder()
.uri(URI.create("$notifyUrl/notify"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
logger.info("Notification sent: status={}", response.statusCode())
}
}
}
```
The Java Agent instruments `java.net.http.HttpClient` automatically, injecting
the `traceparent` header. Trace context is maintained across the
`withContext(Dispatchers.IO)` coroutine switch.
Add the notification service to Docker Compose:
```yaml title="compose.yml (excerpt)"
services:
app:
environment:
OTEL_SERVICE_NAME: ktor-articles
NOTIFY_URL: http://notify:8081
notify:
build: ./notify
environment:
OTEL_SERVICE_NAME: ktor-notify
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
OTEL_LOGS_EXPORTER: otlp
ports:
- "8081:8081"
```
In Scout Dashboard, you'll see the full distributed trace:
```plaintext
ktor-articles: POST /api/articles
+-- INSERT INTO articles ...
+-- POST http://notify:8081/notify
+-- ktor-notify: POST /notify (linked trace)
```
### Ktor-Specific Features
#### Automatic HTTP Request Tracing
The Java Agent instruments Ktor's Netty-based HTTP server automatically.
Every request creates a root span with:
- `http.method` - Request method (GET, POST, etc.)
- `http.route` - Matched route pattern (e.g., `/api/articles/{id}`)
- `http.status_code` - Response status code
- `url.path` - Request URI path
Ktor DSL routing maps directly to span names:
```kotlin
routing {
route("/api/articles") {
get {
// Auto-instrumented: creates span "GET /api/articles"
}
get("/{id}") {
// Auto-instrumented: creates span "GET /api/articles/{id}"
// Uses route pattern, not the actual ID (low cardinality)
}
post {
// Auto-instrumented: creates span "POST /api/articles"
}
}
}
```
#### Exposed ORM Query Tracing
All Exposed queries are traced automatically via JDBC instrumentation.
Each query creates a span with:
- `db.system` - Database type (`postgresql`)
- `db.name` - Database name
- `db.statement` - SQL query (parameters obfuscated)
- `db.operation` - Operation type (`SELECT`, `INSERT`, `UPDATE`, `DELETE`)
```kotlin
// These are all automatically traced:
// Exposed DSL query
suspend fun findAll(page: Int, perPage: Int) = dbQuery {
val total = Articles.selectAll().count()
val articles = Articles.selectAll()
.orderBy(Articles.createdAt, SortOrder.DESC)
.limit(perPage)
.offset((page.toLong() - 1) * perPage.toLong())
.map { it.toDto() }
articles to total
}
// Exposed insert
suspend fun create(title: String, body: String) = dbQuery {
Articles.insert {
it[Articles.title] = title
it[Articles.body] = body
it[createdAt] = OffsetDateTime.now()
it[updatedAt] = OffsetDateTime.now()
}
}
```
In Scout Dashboard, you'll see spans like:
```plaintext
SELECT ... FROM articles ORDER BY ... (db.system=postgresql, db.operation=SELECT)
INSERT INTO articles ... (db.system=postgresql, db.operation=INSERT)
```
#### Kotlin Coroutines Support
The Java Agent propagates trace context across coroutine boundaries
automatically. Suspend functions, `withContext` dispatcher switches, and
`newSuspendedTransaction` all maintain proper trace context:
```kotlin
// Trace context is preserved across the coroutine switch
suspend fun dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
// This suspend function inherits the parent span's context
suspend fun notify(payload: Map) {
withContext(Dispatchers.IO) {
// HTTP call here is still linked to the parent trace
httpClient.send(request, HttpResponse.BodyHandlers.ofString())
}
}
```
#### Flyway Migration Tracing
Database migrations executed by Flyway during application startup are
automatically traced. Each migration file creates a span, giving you
visibility into startup time.
#### Logback Trace-Log Correlation
The Java Agent automatically injects `trace_id` and `span_id` into the
SLF4J MDC. Combined with `logstash-logback-encoder`, every JSON log line
includes trace context:
```json
{
"message": "Article created: id=42, title=Hello",
"logger_name": "com.example.routes.ArticleRoutes",
"level": "INFO",
"trace_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"span_id": "1a2b3c4d5e6f7a8b"
}
```
This is fully automatic — no custom MDC manipulation needed.
### Custom Instrumentation
#### Custom Business Metrics
Create a telemetry service to register and increment custom counters:
```kotlin title="src/main/kotlin/com/example/service/TelemetryService.kt" showLineNumbers
package com.example.service
import io.opentelemetry.api.GlobalOpenTelemetry
import io.opentelemetry.api.metrics.LongCounter
class TelemetryService {
private val articlesCreated: LongCounter = GlobalOpenTelemetry.getMeter("ktor-articles")
.counterBuilder("articles.created")
.setDescription("Total number of articles created")
.build()
fun incrementArticlesCreated() {
articlesCreated.add(1)
}
}
```
Use it in your route handler:
```kotlin title="src/main/kotlin/com/example/routes/ArticleRoutes.kt (excerpt)"
post {
val request = call.receive()
val article = repository.create(request.title!!, request.body!!)
telemetryService.incrementArticlesCreated()
logger.info("Article created: id={}, title={}", article.id, article.title)
try {
notificationClient.notify(mapOf(
"event" to "article.created",
"article_id" to article.id.toString(),
"title" to article.title
))
} catch (e: Exception) {
logger.warn("Failed to send notification: {}", e.message)
}
call.respond(HttpStatusCode.Created, ArticleResponse(
data = article,
meta = TraceMeta(traceId = currentTraceId())
))
}
```
#### Including Trace ID in API Responses
Include the trace ID in API responses so clients can correlate their
requests with backend traces:
```kotlin
private fun currentTraceId(): String {
val span = Span.current()
val ctx = span.spanContext
return if (ctx.isValid) ctx.traceId else ""
}
```
#### Manual Span Creation
Create custom spans for business-critical operations:
```kotlin title="src/main/kotlin/com/example/service/ReportService.kt" showLineNumbers
package com.example.service
import io.opentelemetry.api.GlobalOpenTelemetry
import io.opentelemetry.api.trace.SpanKind
import io.opentelemetry.api.trace.StatusCode
class ReportService {
private val tracer = GlobalOpenTelemetry.getTracer("report-service", "1.0.0")
suspend fun generateReport(userId: Long, reportType: String): ByteArray {
val span = tracer.spanBuilder("generate_report")
.setSpanKind(SpanKind.INTERNAL)
.setAttribute("report.type", reportType)
.setAttribute("user.id", userId)
.startSpan()
return span.makeCurrent().use { scope ->
try {
val report = buildReport(userId, reportType)
span.setAttribute("report.size_bytes", report.size.toLong())
span.setStatus(StatusCode.OK)
report
} catch (e: Exception) {
span.recordException(e)
span.setStatus(StatusCode.ERROR, e.message ?: "Unknown error")
throw e
} finally {
span.end()
}
}
}
}
```
### Running Your Instrumented Application
#### Development Mode
Run locally with Gradle and the Java Agent:
```bash
# Download the agent (one-time)
curl -L -o opentelemetry-javaagent.jar \
https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.26.1/opentelemetry-javaagent.jar
# Set environment variables
export JAVA_TOOL_OPTIONS="-javaagent:./opentelemetry-javaagent.jar"
export OTEL_SERVICE_NAME=ktor-app-dev
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# Run with Gradle
./gradlew run
```
#### Docker Deployment
Run the full stack with Docker Compose:
```bash
# Start all services
docker compose up --build
# Wait for services to be healthy (~30 seconds)
docker compose ps
# Verify the app is running
curl http://localhost:8080/api/health
```
Expected health check response:
```json
{ "status": "healthy", "database": "connected" }
```
#### Verifying Instrumentation
Make test requests and check that traces appear:
```bash
# Create an article
curl -X POST http://localhost:8080/api/articles \
-H "Content-Type: application/json" \
-d '{"title": "Hello OpenTelemetry", "body": "Tracing with Ktor"}'
# List articles
curl http://localhost:8080/api/articles
# Get a specific article
curl http://localhost:8080/api/articles/1
```
The expected span hierarchy for a create request:
```plaintext
POST /api/articles (SERVER - java-agent)
+-- HikariCP getConnection (INTERNAL - java-agent)
+-- INSERT INTO articles ... (CLIENT - java-agent/jdbc)
+-- POST http://notify:8081/notify (CLIENT - java-agent/http)
+-- ktor-notify: POST /notify (SERVER - java-agent)
```
For a list request:
```plaintext
GET /api/articles (SERVER - java-agent)
+-- HikariCP getConnection (INTERNAL - java-agent)
+-- SELECT ... FROM articles ... (CLIENT - java-agent/jdbc)
+-- SELECT COUNT(*) FROM articles (CLIENT - java-agent/jdbc)
```
Check for:
- **Spans** with correct `service.name` and proper nesting
- **Logs** with `trace_id` and `span_id` in the JSON output
- **Metrics** with `articles.created` counter incrementing
### Troubleshooting
#### Verifying Agent Attachment
```bash
# Check that the agent is loaded
docker compose logs app | grep -i "opentelemetry"
# Verify JAVA_TOOL_OPTIONS is set
docker compose exec app env | grep JAVA_TOOL_OPTIONS
```
You should see:
```plaintext
[otel.javaagent] opentelemetry-javaagent - version: 2.26.1
```
#### Common Issues
##### Issue: No traces appearing in Scout Dashboard
**Solutions:**
1. Verify the agent JAR is present and attached:
```bash
ls -la /app/opentelemetry-javaagent.jar
echo $JAVA_TOOL_OPTIONS
```
2. Check the collector endpoint is reachable:
```bash
curl -v http://otel-collector:4318/v1/traces
```
3. Enable debug logging on the agent:
```bash
export OTEL_JAVAAGENT_DEBUG=true
```
4. Check collector logs for errors:
```bash
docker compose logs otel-collector
```
##### Issue: No JDBC/Exposed query spans
**Solutions:**
1. Verify the agent is attached (see above). JDBC instrumentation is
included by default.
2. Check that the database connection is working:
```bash
curl http://localhost:8080/api/health
```
3. Ensure HikariCP is connecting successfully — check app logs for pool
initialization messages.
##### Issue: No trace context propagation between services
**Solutions:**
1. Verify both services have the Java Agent attached:
```bash
docker compose logs app | head -20
docker compose logs notify | head -20
```
2. Ensure the HTTP client being used is supported. The standard
`java.net.http.HttpClient` used in the example is instrumented
automatically.
##### Issue: Log correlation not working (missing trace_id)
**Solutions:**
1. Verify `logstash-logback-encoder` is in dependencies:
```bash
./gradlew dependencies | grep logstash
```
2. Check that `logback.xml` uses `LogstashEncoder` with MDC key names:
```xml
trace_id
span_id
```
##### Issue: High memory usage
**Solutions:**
1. Reduce the batch queue size:
```bash
export OTEL_BSP_MAX_QUEUE_SIZE=1024
```
2. Set JVM heap limits:
```bash
export JAVA_TOOL_OPTIONS="-javaagent:/app/opentelemetry-javaagent.jar -Xmx512m"
```
### Security Considerations
#### SQL Parameter Obfuscation
The Java Agent automatically obfuscates SQL parameter values in database
spans:
```sql
-- What gets executed (never sent to collector)
SELECT * FROM users WHERE email = 'user@example.com' AND api_key = 'sk-abc123'
-- What appears in the span (obfuscated)
SELECT * FROM users WHERE email = ? AND api_key = ?
```
#### Protecting Sensitive Data
Never add sensitive information to span attributes:
```kotlin
// Bad - exposes sensitive data
span.setAttribute("user.password", user.password) // Never!
span.setAttribute("user.email", user.email) // PII risk
// Good - uses safe identifiers
span.setAttribute("user.id", user.id)
span.setAttribute("user.role", user.role)
```
#### Compliance Considerations
For applications handling regulated data (GDPR, HIPAA, PCI-DSS):
- SQL obfuscation is enabled by default
- Never include personally identifiable information (PII) in spans
- Use hashed or anonymized user identifiers
- Implement data retention policies in Scout Dashboard
### Performance Considerations
#### Expected Performance Impact
The OpenTelemetry Java Agent adds minimal overhead to Ktor applications:
- **Average latency increase**: 3-5ms per request
- **CPU overhead**: Less than 5% with batch processing
- **Memory overhead**: ~50-80MB for the agent itself
- **Startup time**: ~1-3 seconds additional for agent initialization
#### Optimization Best Practices
##### 1. Use Batch Span Processing
```bash
OTEL_BSP_MAX_QUEUE_SIZE=2048
OTEL_BSP_SCHEDULE_DELAY=5000
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
```
##### 2. Enable GZIP Compression
```bash
OTEL_EXPORTER_OTLP_COMPRESSION=gzip
```
Reduces network bandwidth by 70-80%.
##### 3. Filter Health Check Endpoints
```yaml title="config/otel-config.yaml (excerpt)"
processors:
filter/noisy:
error_mode: ignore
traces:
span:
- 'IsMatch(name, ".*health.*")'
```
##### 4. Disable Unused Instrumentation
```bash
OTEL_INSTRUMENTATION_KAFKA_ENABLED=false
OTEL_INSTRUMENTATION_GRPC_ENABLED=false
```
### Frequently Asked Questions
#### Does the OpenTelemetry Java Agent impact Ktor performance?
The agent adds approximately 3-5ms of latency per request. With batch
processing and GZIP compression, the overhead is minimal. The agent uses
bytecode manipulation at class load time, so there's a small startup cost
(~1-3 seconds) but negligible runtime impact.
#### Which Ktor versions are supported?
The OpenTelemetry Java Agent supports Ktor 2.x and 3.x running on Netty.
Ktor 3.x with Kotlin 2.x is recommended for optimal compatibility.
#### Are Exposed ORM queries traced automatically?
Yes. The agent intercepts all JDBC calls, which includes every query
Exposed executes through its JDBC layer. Spans include the SQL statement
(parameters obfuscated), database name, and operation type.
#### Does the Java Agent work with Kotlin coroutines?
Yes. The agent propagates trace context across coroutine boundaries
automatically. Suspend functions, `withContext` dispatcher switches, and
`newSuspendedTransaction` all maintain proper trace context.
#### Can I use the Java Agent with GraalVM native images?
No. The Java Agent relies on JVM bytecode manipulation, which is not
available in GraalVM native images. For native images, use the
OpenTelemetry SDK with manual instrumentation.
#### What is the difference between the Java Agent and Ktor's built-in tracing?
The **Java Agent** provides comprehensive zero-code instrumentation for
HTTP, JDBC, Netty, and 100+ libraries. Ktor's built-in `CallLogging` plugin
only covers HTTP request logging. The agent is recommended for full
observability.
#### Can I use the Java Agent alongside other APM tools?
Yes, the agent can coexist with tools like New Relic or Datadog during
migration periods. Running multiple JVM agents simultaneously increases
startup time and memory usage.
#### How do I instrument Ktor WebSockets?
The Java Agent instruments WebSocket frames automatically when using Ktor's
WebSocket plugin on Netty. Each WebSocket connection creates a span.
#### How do I add tenant context in multi-tenant applications?
Use a Ktor interceptor to add tenant attributes:
```kotlin
intercept(ApplicationCallPipeline.Monitoring) {
val tenantId = call.request.headers["X-Tenant-ID"]
if (tenantId != null) {
Span.current().setAttribute("tenant.id", tenantId)
}
}
```
#### Does `kotlinx.serialization` affect tracing?
No. Serialization happens within the already-instrumented HTTP handler span.
There's no separate serialization instrumentation needed.
#### How do I correlate logs with traces in Ktor?
The Java Agent automatically injects `trace_id` and `span_id` into the SLF4J
MDC. Use Logback with `logstash-encoder` to output JSON logs that include
these fields for trace-log correlation.
### What's Next?
Now that your Ktor application is instrumented with OpenTelemetry, explore
these resources:
#### Advanced Topics
- **Custom Java Instrumentation** - Manual tracing, custom spans, and
advanced instrumentation patterns
- **PostgreSQL Monitoring Best Practices** - Database observability with
connection pooling metrics and query performance analysis
#### Scout Platform Features
- **Creating Alerts** - Set up alerts for error rates, latency thresholds,
and custom metrics
- **Dashboard Creation** - Build custom dashboards combining traces,
metrics, and business KPIs
#### Deployment and Operations
- **Docker Compose Setup** - Set up Scout Collector for local development
#### Related Guides
- [Spring Boot Instrumentation](./spring-boot.md) - Most widely used JVM web
framework
- [Quarkus Instrumentation](./quarkus.md) - Kubernetes-native JVM framework
- [Java Custom Instrumentation](../custom-instrumentation/java.md) - Manual
spans and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
### Complete Example
#### Project Structure
```plaintext
ktor-postgres/
+-- app/
| +-- src/main/
| | +-- kotlin/com/example/
| | | +-- Application.kt
| | | +-- model/
| | | | +-- Article.kt
| | | +-- plugins/
| | | | +-- Routing.kt
| | | | +-- Serialization.kt
| | | +-- repository/
| | | | +-- ArticleRepository.kt
| | | +-- routes/
| | | | +-- ArticleRoutes.kt
| | | | +-- HealthRoutes.kt
| | | +-- service/
| | | +-- NotificationClient.kt
| | | +-- TelemetryService.kt
| | +-- resources/
| | +-- logback.xml
| | +-- db/migration/
| | +-- V1__create_articles.sql
| +-- build.gradle.kts
| +-- Dockerfile
+-- notify/
| +-- src/main/kotlin/com/example/notify/
| | +-- Application.kt
| +-- build.gradle.kts
| +-- Dockerfile
+-- config/
| +-- otel-config.yaml
+-- compose.yml
+-- .env.example
+-- scripts/
+-- test-api.sh
+-- verify-scout.sh
```
#### Running the Example
```bash
# Clone the examples repository
git clone https://github.com/base-14/examples.git
cd examples/kotlin/ktor-postgres
# Copy environment file
cp .env.example .env
# Start the stack
docker compose up --build
# Wait for services to be healthy (~30 seconds)
curl http://localhost:8080/api/health
# Run the full test suite
./scripts/test-api.sh
```
#### Testing the API
```bash
# Create an article
curl -s -X POST http://localhost:8080/api/articles \
-H "Content-Type: application/json" \
-d '{"title": "OpenTelemetry with Ktor", "body": "Full observability"}' | jq .
# List articles
curl -s http://localhost:8080/api/articles | jq .
# Update an article
curl -s -X PUT http://localhost:8080/api/articles/1 \
-H "Content-Type: application/json" \
-d '{"title": "Updated Title"}' | jq .
# Delete an article
curl -s -X DELETE http://localhost:8080/api/articles/1
```
#### Expected Trace Output
```plaintext
POST /api/articles (3ms)
+-- HikariCP getConnection (1ms)
+-- INSERT INTO articles ... (4ms)
+-- POST http://notify:8081/notify (15ms)
+-- [ktor-notify] POST /notify (8ms)
```
Once telemetry is flowing, you can monitor Ktor request performance in
Scout — track Exposed query times, HTTP client latency, and error rates
from a unified dashboard.
### References
- [Official OpenTelemetry Java Documentation](https://opentelemetry.io/docs/languages/java/)
- [OpenTelemetry Java Agent](https://github.com/open-telemetry/opentelemetry-java-instrumentation)
- [Supported Libraries (Java Agent)](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md)
- [Ktor Documentation](https://ktor.io/docs/welcome.html)
- [Exposed ORM Documentation](https://jetbrains.github.io/Exposed/)
- [OpenTelemetry Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/)
---
## LangChain OpenTelemetry Instrumentation - Callback Handler Tracing Guide
## LangChain
Instrument a LangChain agent with OpenTelemetry to get one trace across the
whole request: the HTTP call, the agent run, every LLM turn, each tool
invocation, the RAG retrieval, and the database write. This guide takes two
paths. First, zero-code auto-instrumentation that produces traces in minutes.
Then a hand-written callback handler that emits clean GenAI
semantic-convention spans when you need control over names, attributes, cost
metrics, and content capture.
Both paths rest on the same mechanism: **a LangChain callback handler**. The
zero-code library injects one for you; the custom path is you writing your
own.
The example is a small SRE runbook assistant. You POST an incident question;
a tool-calling agent retrieves the relevant runbook from a pgvector store,
inspects service metrics, logs, and status through tools, and returns a cited
diagnosis. That shape - agent, tools, retrieval, database - is where a flat
log stream stops being enough. Your own agent will differ in the details; the
handler is the same either way.
:::tip TL;DR
For a quick start, `pip install opentelemetry-instrumentation-langchain` and
call `LangchainInstrumentor().instrument()` - traces appear in Scout with no
code changes. For production-grade GenAI spans, attach a custom
`BaseCallbackHandler` that maps LangChain's `run_id` / `parent_run_id` run
tree to OpenTelemetry spans with `gen_ai.*` attributes, token and cost
metrics, and content capture off by default.
:::
:::note Running this in production
Storing and querying these traces at production volume is what base14 Scout
does.
[Check out Scout LLM Observability](https://base14.io/scout/llm-observability).
:::
### LangChain vs LangGraph
LangChain 1.x agents built with `create_agent` are **LangGraph graphs under
the hood** - a stateful `model` / `tools` graph, not the old `AgentExecutor`
loop. This guide covers the high-level `create_agent` API and
callback-handler instrumentation, where you observe the agent as a run tree
without touching graph internals. The
[LangGraph guide](./langgraph.md) covers the complementary case: a
hand-built `StateGraph` where you wrap each node and edge with spans
yourself. If you build graphs by hand, read that guide; if you use
`create_agent` and want the agent, tools, and retrieval traced, stay here.
### Who This Guide Is For
This documentation is designed for:
- **AI/ML engineers**: building LangChain agents and needing visibility into
which tool ran, how many tokens a turn used, and where latency accrues.
- **Backend developers**: adding an agent endpoint to an existing FastAPI
service and wanting one trace across HTTP, agent, LLM, and database.
- **Platform teams**: standardizing GenAI observability on OpenTelemetry
semantic conventions instead of a vendor-specific tracer.
- **DevOps and SRE engineers**: deploying LangChain services with cost
tracking, error visibility, and unified traces in production.
### Overview
This guide demonstrates how to:
- Enable zero-code LangChain tracing with OpenLLMetry auto-instrumentation.
- Understand why every LangChain instrumentor is a callback handler.
- Build a custom `OTelCallbackHandler` that emits GenAI-semconv spans for
chains, chat models, tools, and retrievers.
- Parent spans correctly across threads and `await` boundaries using the
run tree, not the ambient context.
- Record token, cost, and duration metrics per LLM call.
- Capture prompts and completions safely, off by default, with PII scrubbing.
- Handle real failures and graceful degradation without leaking spans.
- Read the resulting span tree and avoid the common pitfalls.
- Wire the collector to base14 Scout with the dual-key environment
convention.
### Prerequisites
Before starting, ensure you have:
- **Python 3.10 or later** installed. That is the floor for LangChain 1.x and
the OpenTelemetry SDK, which is what the callback handler needs.
- **A running collector** reachable over OTLP - see
[Docker Compose Setup](../../collector-setup/docker-compose-example.md).
- **PostgreSQL 18 with pgvector** for the runbook store (the compose file
below provisions it).
- **An LLM**: a local
[Ollama](https://ollama.com) model works with no API key; Anthropic,
OpenAI, and Google are configurable alternatives.
- Basic familiarity with OpenTelemetry traces, spans, and metrics.
#### Compatibility Matrix
What the instrumentation itself needs:
| Component | Minimum | Recommended |
| ------------------------------------------ | ------- | ----------- |
| Python | 3.10 | 3.13+ |
| langchain | 1.3 | 1.3.13+ |
| langchain-core | 1.4 | 1.4.9+ |
| opentelemetry-sdk / api | 1.40.0 | 1.40+ |
| opentelemetry-instrumentation-langchain | 0.62 | 0.62.1+ |
The `BaseCallbackHandler` lifecycle this guide builds on has been stable since
LangChain 0.1, so the handler ports back further than the table suggests. The
`create_agent` API is the part that needs 1.x.
Everything else depends on what your app already uses. The example app happens
to run FastAPI, SQLAlchemy, and pgvector on PostgreSQL 18, and pins Python 3.14
for unrelated reasons - none of that is required to instrument LangChain. Swap
in your own web framework and datastore; the handler does not care.
`create_agent` pulls LangGraph in transitively. For LangGraph's own node and
graph spans, see [LangGraph](./langgraph.md).
Treat every package as version-sensitive and re-pin at build time; the
LangChain and OpenLLMetry lines move quickly.
### Installation
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
```bash showLineNumbers title="Terminal"
# Core - LangChain plus the OpenTelemetry SDK and an OTLP exporter
pip install \
langchain langchain-core \
opentelemetry-sdk \
opentelemetry-exporter-otlp-proto-http
# Zero-code path only, for the quick start below
pip install opentelemetry-instrumentation-langchain
# Whichever LLM provider you use
pip install langchain-ollama
# langchain-anthropic | langchain-openai | langchain-google-genai
# Instrumentation for the rest of your stack, so LangChain spans
# join the same trace as your HTTP and database calls
pip install \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-httpx
```
```mdx-code-block
```
```bash showLineNumbers title="Terminal"
# Core - LangChain plus the OpenTelemetry SDK and an OTLP exporter
uv add \
langchain langchain-core \
opentelemetry-sdk \
opentelemetry-exporter-otlp-proto-http
# Zero-code path only, for the quick start below
uv add opentelemetry-instrumentation-langchain
# Whichever LLM provider you use
uv add langchain-ollama
# langchain-anthropic | langchain-openai | langchain-google-genai
# Instrumentation for the rest of your stack, so LangChain spans
# join the same trace as your HTTP and database calls
uv add \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-httpx
```
```mdx-code-block
```
:::warning Package name collision
The PyPI name `opentelemetry-instrumentation-langchain` is **Traceloop's
OpenLLMetry** package, not the official OpenTelemetry GenAI SIG one.
`pip install opentelemetry-instrumentation-langchain` installs Traceloop's
build. It is mature and exports to any OTLP collector, so it is the right
choice to start. The official SIG instrumentation ships from the
`opentelemetry-python-contrib` source tree and is the emerging
`gen_ai`-native standard to track. See
[Choosing an Approach](#choosing-an-approach).
:::
### Configuration
Instrumentation is configured entirely through environment variables. These
are the standard OpenTelemetry ones, read by the SDK itself:
```bash showLineNumbers title=".env"
OTEL_SERVICE_NAME=your-agent-service
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production
OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
```
| Variable | Default | What it controls |
| -------- | ------- | ---------------- |
| `OTEL_SERVICE_NAME` | unset | Becomes `service.name` on every span. Set it, or your agent shows up as `unknown_service`. |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4318` | OTLP HTTP endpoint of your collector. |
| `OTEL_RESOURCE_ATTRIBUTES` | unset | Comma-separated resource attributes. Use it for `deployment.environment.name`. |
| `OTEL_SEMCONV_STABILITY_OPT_IN` | unset | Set to `gen_ai_latest_experimental` to opt into the current GenAI conventions rather than the frozen older set. |
Then there is content capture, which is the one GenAI-specific decision and
the one that carries privacy consequences. Each path has its own flag, and
they default in opposite directions:
| Path | Variable | Default |
| ---- | -------- | ------- |
| Custom callback handler | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | `false` |
| OpenLLMetry auto-instrumentation | `TRACELOOP_TRACE_CONTENT` | **`true`** |
If you start with the zero-code path, prompts and completions are recorded
unless you turn them off:
```bash showLineNumbers title="Terminal"
export TRACELOOP_TRACE_CONTENT=false
```
See [Content Capture and PII](#content-capture-and-pii) before enabling either
in production.
:::tip One switch for the whole thing
Worth copying from the example app: a single `INSTRUMENTATION_MODE` setting
with values `auto`, `callback`, and `off`, read once at startup. It lets you
compare the two paths against the same workload and kill instrumentation
outright without a redeploy. Your own app config (model names, datastore URLs,
API keys) stays separate from this and has no bearing on the telemetry.
:::
### Quick Start: Auto-Instrumentation
The fastest path to a trace is zero-code. Enable OpenLLMetry's
`LangchainInstrumentor` once, before you build the agent, and every LangChain
run emits spans.
```python showLineNumbers title="src/runbook_assistant/telemetry/auto.py"
"""Zero-code LangChain instrumentation via OpenLLMetry (Traceloop)."""
import logging
logger = logging.getLogger(__name__)
_enabled = False
def enable_auto_instrumentation() -> None:
global _enabled
if _enabled:
return
from opentelemetry.instrumentation.langchain import LangchainInstrumentor
LangchainInstrumentor().instrument()
_enabled = True
logger.info("OpenLLMetry LangChain auto-instrumentation enabled")
```
That is the entire integration. Point the OTLP exporter at your collector
(see [Scout Wiring](#scout-wiring)), send a request, and the trace appears in
Scout. With the agent answering an incident question, the auto-instrumented
trace looks like this:
```text
POST /api/v1/diagnose SERVER (FastAPI)
└─ invoke_agent LangGraph INTERNAL
└─ LangGraph.workflow INTERNAL
├─ execute_task model INTERNAL (graph node)
│ └─ ChatOllama.chat CLIENT (LLM turn)
│ └─ POST /api/chat CLIENT (httpx → Ollama)
└─ execute_task tools INTERNAL (graph node)
├─ execute_tool search_runbooks INTERNAL
│ └─ vector_db_retrieve VectorStoreRetriever CLIENT (retrieval)
└─ execute_tool query_metrics INTERNAL
(repeats model → tools until the agent answers)
```
Notice how much shows up for free: the agent run, each LangGraph node
(`model`, `tools`), the LLM turns, the tool calls, and the vector retrieval.
The LLM spans carry token usage, and the whole tree nests under the FastAPI
server span, so the agent shares a trace with your HTTP and database
telemetry.
#### Auto-Instrumentation Is a Callback Handler
`LangchainInstrumentor().instrument()` works by monkeypatching
`langchain_core.callbacks.BaseCallbackManager.__init__` to inject its own
callback handler into every run. Traceloop OpenLLMetry, the official OTel
GenAI SIG instrumentation, and OpenInference all do the same thing - they add
a handler for you.
LangChain emits observability through callback handlers. Auto-instrumentation
injects one; the deep dive below is you writing your own.
#### Where Auto-Instrumentation Falls Short
The zero-code trace is excellent for exploration, but a few things make a
custom handler worthwhile. These are observed from OpenLLMetry 0.62.1 against
a local `ChatOllama` model:
- **Framework-centric span names.** The LLM span is `ChatOllama.chat`, not
the GenAI-semconv `chat {model}`; the agent is `invoke_agent LangGraph`
rather than your service name; retrieval is
`vector_db_retrieve VectorStoreRetriever`.
- **Model field is `unknown`.** For Ollama, `gen_ai.request.model` and
`gen_ai.response.model` come through as `"unknown"`; the real model name is
only in a proprietary `traceloop.association.properties.ls_model_name`
attribute.
- **Proprietary attributes.** Every span carries `traceloop.*` association
properties alongside the `gen_ai.*` ones.
- **Content on by default.** Auto-instrumentation records the full prompt,
system message, tool arguments, and retrieved documents in span attributes
such as `gen_ai.input.messages` and `gen_ai.task.input`. Crucially, it
**ignores** `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`; it is
gated by OpenLLMetry's own `TRACELOOP_TRACE_CONTENT` (default `true`). To
turn content off, set `TRACELOOP_TRACE_CONTENT=false`.
None of these block you - they are the reasons a team with compliance or
naming requirements moves to the custom handler next.
### Callback-Handler Deep Dive
#### Why Callbacks
A LangChain run is a **tree**. Every step - the agent, each LLM call, each
tool, each retrieval - has a `run_id`, and every child step carries its
parent's id as `parent_run_id`. LangChain reports lifecycle events for each
node to any registered callback handler:
| LangChain event | Fires when |
| -------------------------------------- | ------------------------------ |
| `on_chain_start` / `on_chain_end` | a chain or agent run begins |
| `on_chat_model_start` / `on_llm_end` | an LLM turn begins and returns |
| `on_tool_start` / `on_tool_end` | a tool is invoked |
| `on_retriever_start` / `on_retriever_end` | a retriever runs |
| `on_*_error` | any of the above raises |
Mapping that run tree onto an OpenTelemetry span tree is the whole job: start
a span on each `*_start`, end it on the matching `*_end`, and parent each
span on its `parent_run_id`'s span.
#### Telemetry Setup
Bootstrap the SDK once, before the app serves traffic. This wires the OTLP
exporters for all three signals, the dual-key environment resource, and base
auto-instrumentation for httpx, SQLAlchemy, and logging.
```python showLineNumbers title="src/runbook_assistant/telemetry/setup.py"
def build_resource() -> Resource:
"""Resource with the dual-key environment convention."""
s = get_settings()
return Resource.create(
{
"service.name": s.otel_service_name,
"service.version": _APP_VERSION,
# Dual-key: Scout UI filters on lowercase `environment`.
"deployment.environment.name": s.scout_environment,
"environment": s.scout_environment,
}
)
```
```python showLineNumbers title="src/runbook_assistant/telemetry/setup.py"
def setup_telemetry(engine: Any = None) -> tuple[trace.Tracer, metrics.Meter]:
resource = build_resource()
endpoint = os.environ.get(
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"
)
trace_provider = TracerProvider(resource=resource)
trace_provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint=f"{endpoint}/v1/traces"))
)
trace.set_tracer_provider(trace_provider)
# Required for the GenAI token, cost, duration, and error instruments
# in the Metrics section below - without a meter provider they no-op.
reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=f"{endpoint}/v1/metrics"),
export_interval_millis=10_000,
)
metrics.set_meter_provider(
MeterProvider(resource=resource, metric_readers=[reader])
)
# OTLP logs carry the active trace_id/span_id for trace<>log correlation.
log_provider = LoggerProvider(resource=resource)
log_provider.add_log_record_processor(
BatchLogRecordProcessor(OTLPLogExporter(endpoint=f"{endpoint}/v1/logs"))
)
_logs.set_logger_provider(log_provider)
HTTPXClientInstrumentor().instrument()
LoggingInstrumentor().instrument(set_logging_format=True)
if engine is not None:
SQLAlchemyInstrumentor().instrument(engine=engine.sync_engine)
return (
trace.get_tracer("langchain-agent"),
metrics.get_meter("langchain-agent"),
)
```
If you take the zero-code path, call `enable_auto_instrumentation()` at the end
of this function, after the providers exist and before the agent is built.
The httpx instrumentation matters more than it looks: LangChain has no
embeddings callback event, and Ollama chat and embeddings both go over HTTP.
So even without a semantic span, the `POST /api/chat` and `POST /api/embed`
transport calls show up as CLIENT spans under the LLM and retrieval steps.
#### Build the Callback Handler
The handler keeps a `run_id -> span` map and a small `_RunState` per run. The
central correctness point is `_parent_ctx`: it parents a new span on the
**stored** parent span, never on `trace.get_current_span()`, because
LangChain callbacks can fire on worker threads or across `await`.
```python showLineNumbers title="src/runbook_assistant/telemetry/callback.py"
class OTelCallbackHandler(BaseCallbackHandler):
def __init__(
self,
tracer: trace.Tracer | None = None,
agent_name: str = "agent",
data_source_id: str = "knowledge_base",
conversation_id: str | None = None,
) -> None:
self._tracer = tracer or trace.get_tracer("langchain.callback")
self._agent_name = agent_name
self._data_source_id = data_source_id
self._conversation_id = conversation_id
self._runs: dict[UUID, _RunState] = {}
self._metrics = get_metrics()
self._capture = get_settings().capture_content
def _parent_ctx(self, parent_run_id: UUID | None) -> otel_context.Context | None:
if parent_run_id is not None and parent_run_id in self._runs:
return trace.set_span_in_context(self._runs[parent_run_id].span)
return None
def _start(self, run_id, parent_run_id, name, kind) -> Span:
span = self._tracer.start_span(
name, context=self._parent_ctx(parent_run_id), kind=kind
)
self._runs[run_id] = _RunState(span, time.perf_counter())
return span
```
:::danger Parent on the run tree, not the ambient context
Because callbacks can arrive off the request thread,
`trace.get_current_span()` may point at the wrong span, or none. Always
resolve the parent from your own `run_id -> span` map via `parent_run_id`.
Skipping this is the most common cause of mis-nested LangChain traces.
:::
##### The chain (agent) span
The root chain run - the one with no `parent_run_id` - becomes the
`invoke_agent` span, named after your service. Intermediate LangGraph node
runs (`model`, `tools`) are collapsed to a pass-through so the tree shows the
meaningful chat, tool, and retrieval spans instead of graph plumbing.
```python showLineNumbers title="src/runbook_assistant/telemetry/callback.py"
def on_chain_start(
self, serialized, inputs, *, run_id, parent_run_id=None,
tags=None, metadata=None, **kwargs,
) -> None:
if parent_run_id is None:
span = self._start(
run_id, None, f"invoke_agent {self._agent_name}", SpanKind.INTERNAL
)
span.set_attribute("gen_ai.operation.name", "invoke_agent")
span.set_attribute("gen_ai.agent.name", self._agent_name)
if self._conversation_id:
span.set_attribute("gen_ai.conversation.id", self._conversation_id)
else:
# Skip the span for intermediate LangGraph nodes; pass-through keeps
# child spans nested under the nearest real ancestor.
parent = self._runs.get(parent_run_id)
if parent is not None:
self._runs[run_id] = _RunState(
parent.span, parent.start, owns_span=False
)
```
Whether you collapse node runs or keep them is a judgment call. Collapsing
gives a clean agent-centric tree; keeping them mirrors the auto-instrumented
view where `execute_task model` and `execute_task tools` are visible. This
example collapses them - see
[Reading the Span Tree](#reading-the-span-tree).
##### The chat model span
`on_chat_model_start` opens a `chat {model}` CLIENT span. The model and
provider come from the run metadata (`ls_model_name`, `ls_provider`), which
is how the handler gets the real model name that auto-instrumentation reports
as `unknown`.
```python showLineNumbers title="src/runbook_assistant/telemetry/callback.py"
def _start_llm(self, run_id, parent_run_id, metadata, messages) -> None:
meta = metadata or {}
model = meta.get("ls_model_name", "unknown")
provider = meta.get("ls_provider", "unknown")
span = self._start(run_id, parent_run_id, f"chat {model}", SpanKind.CLIENT)
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.provider.name", provider)
span.set_attribute("gen_ai.request.model", model)
```
`on_llm_end` reads token usage and the finish reason off the result, sets the
usage attributes, and records the metrics. The finish reason is
provider-aware: Ollama reports `done_reason`, while cloud providers use
`stop_reason` or `finish_reason`.
```python showLineNumbers title="src/runbook_assistant/telemetry/callback.py"
def on_llm_end(self, response: LLMResult, *, run_id: UUID, **kwargs) -> None:
state = self._runs.get(run_id)
if state is None:
return
span = state.span
in_tok, out_tok, finish, resp_model = _usage_from_result(response)
provider = getattr(span, "attributes", {}).get("gen_ai.provider.name", "unknown")
model = resp_model or "unknown"
if resp_model:
span.set_attribute("gen_ai.response.model", resp_model)
if finish:
span.set_attribute("gen_ai.response.finish_reasons", [finish])
span.set_attribute("gen_ai.usage.input_tokens", in_tok)
span.set_attribute("gen_ai.usage.output_tokens", out_tok)
attrs = {
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": provider,
"gen_ai.request.model": model,
}
self._metrics.record_tokens(attrs, in_tok, out_tok)
self._metrics.record_duration(attrs, time.perf_counter() - state.start)
cost = calculate_cost(model, in_tok, out_tok)
if cost:
span.set_attribute("gen_ai.usage.cost_usd", cost)
self._metrics.add_cost(attrs, cost)
self._end(run_id)
```
##### The tool span
`on_tool_start` opens an `execute_tool {name}` INTERNAL span with the
GenAI tool attributes.
```python showLineNumbers title="src/runbook_assistant/telemetry/callback.py"
def on_tool_start(
self, serialized, input_str, *, run_id, parent_run_id=None, **kwargs,
) -> None:
name = (serialized or {}).get("name") or "tool"
span = self._start(
run_id, parent_run_id, f"execute_tool {name}", SpanKind.INTERNAL
)
span.set_attribute("gen_ai.operation.name", "execute_tool")
span.set_attribute("gen_ai.tool.name", name)
span.set_attribute("gen_ai.tool.type", "function")
if self._capture and input_str:
from runbook_assistant.pii import scrub
span.set_attribute("gen_ai.tool.call.arguments", scrub(input_str))
```
##### The retrieval span
`on_retriever_start` opens a `retrieval {data_source}` CLIENT span; on end,
the handler records how many chunks came back as a custom `app.*` attribute.
```python showLineNumbers title="src/runbook_assistant/telemetry/callback.py"
def on_retriever_start(
self, serialized, query, *, run_id, parent_run_id=None, **kwargs,
) -> None:
span = self._start(
run_id, parent_run_id,
f"retrieval {self._data_source_id}", SpanKind.CLIENT,
)
span.set_attribute("gen_ai.operation.name", "retrieval")
span.set_attribute("gen_ai.data_source.id", self._data_source_id)
def on_retriever_end(self, documents, *, run_id: UUID, **kwargs) -> None:
state = self._runs.get(run_id)
if state is not None:
state.span.set_attribute("app.retrieval.chunk_count", len(documents or []))
self._end(run_id)
```
Custom attributes use the `app.*` prefix so they never collide with reserved
`gen_ai.*` names - `app.retrieval.chunk_count` here, and you might add
`app.retrieval.score_threshold` or similar.
#### The Resulting Spans
With the custom handler, the same incident question produces a clean,
semconv-named tree:
```text
POST /api/v1/diagnose SERVER (FastAPI)
└─ invoke_agent runbook_assistant INTERNAL
├─ chat qwen3.5:9B CLIENT (LLM turn, picks tools)
│ └─ POST /api/chat CLIENT (httpx → Ollama)
├─ execute_tool search_runbooks INTERNAL
│ └─ retrieval runbooks CLIENT
│ ├─ POST /api/embed CLIENT (httpx → Ollama)
│ └─ SELECT runbooks CLIENT (pgvector query)
├─ execute_tool query_metrics INTERNAL
├─ execute_tool search_logs INTERNAL
├─ execute_tool get_service_status INTERNAL
└─ chat qwen3.5:9B CLIENT (LLM turn, writes answer)
└─ INSERT diagnoses CLIENT (SQLAlchemy)
```
The attributes on each span type:
| Span | Kind | Key attributes |
| ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invoke_agent runbook_assistant` | INTERNAL | `gen_ai.operation.name=invoke_agent`, `gen_ai.agent.name`, `gen_ai.conversation.id` |
| `chat qwen3.5:9B` | CLIENT | `gen_ai.operation.name=chat`, `gen_ai.provider.name`, `gen_ai.request.model`, `gen_ai.response.model`, `gen_ai.response.finish_reasons`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens` |
| `execute_tool {name}` | INTERNAL | `gen_ai.operation.name=execute_tool`, `gen_ai.tool.name`, `gen_ai.tool.type=function` |
| `retrieval runbooks` | CLIENT | `gen_ai.operation.name=retrieval`, `gen_ai.data_source.id`, `app.retrieval.chunk_count` |
Compared with the auto-instrumented trace, the model name is correct rather
than `unknown`, span names follow the GenAI conventions, there are no
proprietary attributes, and content is absent unless you opt in.
### Metrics
The handler records GenAI metrics on every LLM turn using dedicated
instruments, so cost and token usage aggregate in dashboards independent of
the spans.
```python showLineNumbers title="src/runbook_assistant/telemetry/metrics.py"
class GenAIMetrics:
def __init__(self) -> None:
meter = metrics.get_meter("gen_ai.client")
self._tokens = meter.create_histogram(
"gen_ai.client.token.usage", unit="{token}",
description="Tokens used per LLM call",
)
self._duration = meter.create_histogram(
"gen_ai.client.operation.duration", unit="s",
description="Duration of GenAI operations",
)
self._cost = meter.create_counter(
"gen_ai.client.cost", unit="usd",
description="Cost of GenAI operations in USD",
)
self._errors = meter.create_counter(
"gen_ai.client.error.count", unit="{error}",
description="GenAI errors by type",
)
def record_tokens(self, attrs, input_tokens, output_tokens) -> None:
self._tokens.record(input_tokens, {**attrs, "gen_ai.token.type": "input"})
self._tokens.record(output_tokens, {**attrs, "gen_ai.token.type": "output"})
def add_error(self, attrs) -> None:
self._errors.add(1, attrs)
```
Cost comes from a small per-model price table. Unknown models return `0.0` so
the calculation never raises - local Ollama has no per-token price, so cost
is zero there and non-zero once you point the agent at a cloud provider.
```python showLineNumbers title="src/runbook_assistant/cost.py"
PRICING: dict[str, tuple[float, float]] = {
"claude-sonnet-4-6": (3.0, 15.0),
"claude-opus-4-8": (5.0, 25.0),
"gpt-4o": (2.5, 10.0),
"gemini-2.5-pro": (1.25, 10.0),
}
def calculate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
rates = PRICING.get(model) or PRICING.get(_normalize(model))
if not rates:
return 0.0
in_rate, out_rate = rates
return (input_tokens * in_rate + output_tokens * out_rate) / 1_000_000
```
With `gen_ai.request.model` and `gen_ai.provider.name` on the metric points,
a dashboard query like `sum(gen_ai.client.cost) by (gen_ai.request.model)`
gives per-model spend, and the token histogram splits input from output via
`gen_ai.token.type`.
### Content Capture and PII
Prompts and completions are **off by default**. The handler captures them
only when `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`, and even
then it runs the text through a scrubber first and records it as a span event,
not a raw attribute.
```python showLineNumbers title="src/runbook_assistant/pii.py"
_EMAIL = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
_IPV4 = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
_BEARER = re.compile(r"(?i)bearer\s+[a-z0-9._-]+")
_APIKEY = re.compile(r"\b(sk-|key-)[A-Za-z0-9]{8,}\b")
def scrub(text: str, limit: int = 1000) -> str:
if not text:
return ""
text = _EMAIL.sub("[email]", text)
text = _IPV4.sub("[ip]", text)
text = _BEARER.sub("[token]", text)
text = _APIKEY.sub("[key]", text)
return text[:limit]
```
:::warning Auto-instrumentation captures content by default
The custom handler above respects
`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`. OpenLLMetry
auto-instrumentation does **not** - it records full prompts, tool arguments,
and retrieved documents by default and is controlled by its own
`TRACELOOP_TRACE_CONTENT` variable. If you run auto mode in an environment
with sensitive data, set `TRACELOOP_TRACE_CONTENT=false` explicitly.
:::
### Error Handling
The handler distinguishes a real failure from a failure the agent recovers
from. A failed LLM call records the exception, sets `error.type`, and marks
the span `ERROR`. A tool that fails but that the agent works around marks the
tool span `ERROR` while leaving the parent `invoke_agent` span green, adding
an event instead - the request succeeded, so the agent span should not read
as failed.
```python showLineNumbers title="src/runbook_assistant/telemetry/callback.py"
def _error(self, run_id: UUID, error: BaseException) -> None:
state = self._runs.pop(run_id, None)
if state is None or not state.owns_span:
return
span = state.span
span.record_exception(error)
span.set_attribute("error.type", type(error).__name__)
span.set_status(Status(StatusCode.ERROR, str(error)))
span.end()
def on_tool_error(self, error, *, run_id, parent_run_id=None, **kwargs) -> None:
self._error(run_id, error)
parent = self._runs.get(parent_run_id) if parent_run_id else None
if parent is not None:
parent.span.add_event(
"tool_execution_failed", {"error.type": type(error).__name__}
)
```
Retrieval failure follows the same graceful-degradation shape: the retriever
span is marked `ERROR`, and the parent gets a `rag_retrieval_degraded` event
rather than an error status. Because every `_error` path pops the `run_id`
from the map, a failed run never orphans a span or leaks a stored context.
Record the error on the metric side too. Spans tell you about one failed
request; the `gen_ai.client.error.count` counter tells you the rate, and it
survives sampling:
```python showLineNumbers title="src/runbook_assistant/telemetry/callback.py"
def on_llm_error(self, error, *, run_id: UUID, **kwargs) -> None:
state = self._runs.get(run_id)
self._metrics.add_error(
{
"gen_ai.operation.name": "chat",
"error.type": type(error).__name__,
# Read provider from your own run state, not from the span
"gen_ai.provider.name": state.provider if state else "unknown",
}
)
self._error(run_id, error)
```
Keep `provider` and `model` on your `_RunState` rather than reading them back
off the span. `Span.attributes` exists on the SDK's `ReadableSpan` but not on
the tracing API, so a sampled-out run hands you a `NonRecordingSpan` and the
attribute silently degrades to `unknown` on the metric point.
### Reading the Span Tree
A few things to look for, and the pitfalls that produce a confusing trace:
- **Two LLM turns per diagnosis.** The first `chat` span chooses tools; the
final one writes the answer. If you see only one, the agent answered
without calling a tool.
- **Retrieval nests under its tool.** `retrieval runbooks` is a child of
`execute_tool search_runbooks`, and the `POST /api/embed` and
`SELECT runbooks` spans nest under retrieval - embedding then vector query.
- **LangGraph node noise.** In auto mode you also see `execute_task model`
and `execute_task tools`; the custom handler collapses those. If your
custom trees look too deep, you are keeping node runs instead of collapsing
them.
- **Orphan or mis-nested spans** almost always mean a span was parented on
the ambient context instead of the run tree. Re-check `_parent_ctx`.
- **Double instrumentation.** Running `auto` and `callback` together
double-instruments every operation. Pick exactly one mode.
### Integration Patterns
There are three places to attach a callback handler; this example uses the
first, one handler per request, so each diagnosis gets its own
`conversation_id`.
```python showLineNumbers title="src/runbook_assistant/main.py"
@app.post("/api/v1/diagnose", response_model=DiagnoseResponse)
async def diagnose(req: DiagnoseRequest) -> DiagnoseResponse:
conversation_id = str(uuid.uuid4())
callbacks = app.state.handler_factory(conversation_id)
answer = run_diagnosis(app.state.agent, req.question, callbacks)
...
```
```python showLineNumbers title="src/runbook_assistant/agent.py"
def run_diagnosis(agent, question, callbacks=None) -> str:
result = agent.invoke(
{"messages": [{"role": "user", "content": question}]},
config={"callbacks": callbacks or []},
)
messages = result.get("messages", [])
return messages[-1].content if messages else ""
```
- **Per-invocation** (shown): pass `config={"callbacks": [...]}` on each
`invoke`. Best when per-request context like a conversation id matters.
- **Constructor**: pass `callbacks=[...]` when building the model or chain.
The handler applies to every call through that object.
- **Global**: register a handler on the callback manager once at startup so
it applies process-wide. This is effectively what auto-instrumentation
does.
### The Example Application
The code excerpts throughout this guide come from a working reference app, an
SRE runbook assistant, at
[base-14/examples/python/ai-runbook-assistant](https://github.com/base-14/examples/tree/main/python/ai-runbook-assistant).
Clone it if you want a running trace to compare yours against. The snippets
below are trimmed for readability - helpers like `_RunState`, `get_settings`,
and the PII scrubber live in that repo rather than in the text here.
The pieces the handler observes are ordinary LangChain. The agent is built
with `create_agent`, which returns a LangGraph-backed graph:
```python showLineNumbers title="src/runbook_assistant/agent.py"
from langchain.agents import create_agent
def build_agent(retriever: Any) -> Any:
return create_agent(
model=build_chat_model(),
tools=build_tools(retriever),
system_prompt=SYSTEM_PROMPT,
)
```
Tools are plain `@tool` functions; their docstrings are the descriptions the
model sees. `search_runbooks` wraps the pgvector retriever, the rest read a
fixture so the example runs offline and the captured trace is reproducible.
```python showLineNumbers title="src/runbook_assistant/tools.py"
def build_search_runbooks(retriever: Any) -> Any:
@tool
def search_runbooks(query: str) -> str:
"""Search the SRE runbook knowledge base for relevant procedures."""
docs = retriever.invoke(query)
if not docs:
return "No matching runbooks found."
return "\n\n".join(
f"# {d.metadata.get('title', 'runbook')}\n{d.page_content}" for d in docs
)
return search_runbooks
```
The retriever is a `PGVector` store over local `embeddinggemma` embeddings.
The LLM is provider-agnostic - Ollama by default, with Anthropic, OpenAI, and
Google selectable by environment variable.
```python showLineNumbers title="src/runbook_assistant/retriever.py"
def build_retriever(connection_string: str) -> tuple[Any, Any]:
from langchain_ollama import OllamaEmbeddings
from langchain_postgres import PGVector
s = get_settings()
embeddings = OllamaEmbeddings(model=s.embedding_model, base_url=s.ollama_base_url)
store = PGVector(
embeddings=embeddings,
collection_name="runbooks",
connection=connection_string,
use_jsonb=True,
)
return store.as_retriever(search_kwargs={"k": 3}), store
```
The `INSTRUMENTATION_MODE` setting selects the path; the app builds the
handler factory accordingly and passes the handlers to each `invoke`.
```python showLineNumbers title="src/runbook_assistant/main.py"
if s.instrumentation_mode == "callback":
app.state.handler_factory = lambda conversation_id: [
OTelCallbackHandler(
agent_name="runbook_assistant",
data_source_id=s.data_source_id,
conversation_id=conversation_id,
)
]
else:
app.state.handler_factory = lambda _conversation_id: []
```
### Choosing an Approach
Every option here works by injecting a LangChain callback handler; they differ
in semantic conventions, maturity, and defaults.
| Approach | Package | Conventions | Notes |
| -------------------------------- | ------------------------------------------------ | ------------------------------ | --------------------------------------------------------------------- |
| **OpenLLMetry (Traceloop)** | `opentelemetry-instrumentation-langchain` | `gen_ai.*` plus `traceloop.*` | Mature, exports to any OTLP collector. **Lead with this.** Content on by default; owns the PyPI name. |
| **Official OTel GenAI SIG** | contrib `instrumentation-genai` (source for now) | `gen_ai.*` native | Emerging standard; being packaged as `opentelemetry-instrumentation-genai-langchain`. Track it. |
| **OpenInference (Arize)** | `openinference-instrumentation-langchain` | own `llm.*` / `openinference.*` | Needs a translation step for `gen_ai.*` alignment. |
| **LangSmith `[otel]`** | `langsmith[otel]` | LangSmith model | Defaults to LangSmith cloud; override with `OTEL_EXPORTER_OTLP_ENDPOINT`. |
| **Custom callback handler** | your code | exactly what you emit | Full control over names, attributes, cost, and content. This guide. |
A practical rule: start with OpenLLMetry, move to the custom handler when you
need clean semconv names, correct model fields, custom retrieval attributes,
or content off by default. Watch the official SIG package and adopt it once it
is pip-installable and covers your needs.
### Related Conventions and Frameworks
Where this fits alongside neighbouring conventions and frameworks:
- **Tools as MCP.** The
[Model Context Protocol](https://modelcontextprotocol.io) is a common tool
layer, and agents increasingly expose tools as MCP servers. If your tools run
in-process, the callback handler traces them as `execute_tool` spans. Once
they sit behind an MCP server, OpenTelemetry's
[MCP spans](https://github.com/open-telemetry/semantic-conventions/tree/main/docs/gen-ai)
cover that hop instead.
- **Agent spans.** The `invoke_agent` and `execute_tool` spans follow the
OpenTelemetry GenAI
[agent and framework span conventions](https://github.com/open-telemetry/semantic-conventions/tree/main/docs/gen-ai),
which supersede the older generic chat-span definitions.
- **Agent to agent.** Multi-agent A2A messaging is out of scope here; the
handler traces a single agent's run tree.
- **Other frameworks.** `create_agent` is one of several agent APIs (LangGraph,
OpenAI Agents SDK, Google ADK, Claude Agent SDK, Pydantic AI, CrewAI). This
guide uses it because it is the idiomatic LangChain 1.x agent and pairs with
the [LangGraph guide](./langgraph.md).
### Scout Wiring
Route the SDK's OTLP output to a collector that forwards to base14 Scout. The
collector authenticates with `oauth2client` and applies the dual-key
environment on the way out.
```yaml showLineNumbers title="otel-collector-config.yaml"
extensions:
health_check:
endpoint: 0.0.0.0:13133
oauth2client:
client_id: ${SCOUT_CLIENT_ID}
client_secret: ${SCOUT_CLIENT_SECRET}
token_url: ${SCOUT_TOKEN_URL}
endpoint_params:
audience: b14collector
timeout: 10s
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
filter/noisy:
error_mode: ignore
traces:
span:
- 'IsMatch(name, ".*/healthz.*")'
- 'IsMatch(name, ".*/readyz.*")'
batch:
timeout: 10s
send_batch_size: 1024
send_batch_max_size: 2048
attributes:
actions:
- key: deployment.environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
- key: environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
exporters:
otlp_http/b14:
endpoint: ${SCOUT_ENDPOINT}
auth:
authenticator: oauth2client
compression: gzip
timeout: 30s
retry_on_failure:
enabled: true
initial_interval: 1s
max_interval: 30s
max_elapsed_time: 300s
debug:
verbosity: detailed
service:
extensions: [health_check, oauth2client]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, filter/noisy, attributes, batch]
exporters: [otlp_http/b14, debug]
metrics:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlp_http/b14, debug]
logs:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlp_http/b14, debug]
```
The dual-key environment is deliberate: the SDK resource sets
`deployment.environment.name` and the collector upserts both
`deployment.environment` and a lowercase `environment` with the same value,
because the Scout UI filters on `environment`.
`filter/noisy` drops health-probe spans from the traces pipeline only. The
`debug` exporter prints every batch to the collector log, which is what the
troubleshooting steps below read; drop it once you are past first setup.
#### Docker Compose
Substitute your own application for the `app` service. The parts that matter
for telemetry are the two `OTEL_*` variables pointing at the collector, and the
collector service itself.
```yaml showLineNumbers title="compose.yaml"
services:
app:
build: .
ports:
- "8000:8000"
environment:
- OTEL_SERVICE_NAME=your-agent-service
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
- OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
- OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=${OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT:-false}
- SCOUT_ENVIRONMENT=${SCOUT_ENVIRONMENT:-development}
# If your LLM runs on the host rather than in a container
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
extra_hosts:
- "host.docker.internal:host-gateway" # needed on Linux, not Docker Desktop
depends_on:
otel-collector:
condition: service_started
otel-collector:
image: otel/opentelemetry-collector-contrib:0.153.0
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
ports:
- "4317:4317"
- "4318:4318"
- "13133:13133"
environment:
- SCOUT_CLIENT_ID=${SCOUT_CLIENT_ID:-}
- SCOUT_CLIENT_SECRET=${SCOUT_CLIENT_SECRET:-}
- SCOUT_TOKEN_URL=${SCOUT_TOKEN_URL:-https://auth.base14.io/oauth/token}
- SCOUT_ENDPOINT=${SCOUT_ENDPOINT:-https://collector.base14.io}
- SCOUT_ENVIRONMENT=${SCOUT_ENVIRONMENT:-development}
```
The collector needs Scout credentials in its own environment, not your app's.
Your app only ever talks OTLP to `otel-collector:4318` and stays unaware of
Scout entirely, which is what lets you point the same app at a different
backend by editing one file.
If your models run on the host rather than in a container, `extra_hosts` is
what makes `host.docker.internal` resolve on Linux. Docker Desktop provides it
without the entry.
### Running Your Application
```bash showLineNumbers title="Terminal"
# start the stack (collector picks up Scout creds from the environment)
docker compose up -d
# send an incident question
curl -s -X POST http://localhost:8000/api/v1/diagnose \
-H 'Content-Type: application/json' \
-d '{"question": "orders-service is returning 500s and the logs show
connection pool exhausted. What is the likely cause?"}'
```
If you adopted the single-switch pattern from
[Configuration](#configuration), you can flip between the two paths without a
code change and compare the traces against the same workload:
```bash showLineNumbers title="Terminal"
INSTRUMENTATION_MODE=auto docker compose up -d --force-recreate app
```
### Troubleshooting
#### Verify Telemetry Is Working
The collector's `debug` exporter prints spans as they arrive. Send a request,
then read the collector's log. If you run it under Docker Compose:
```bash showLineNumbers title="Terminal"
docker compose logs otel-collector --since=1m | grep -E "Name|gen_ai"
```
With the custom handler attached you should see `invoke_agent`, `chat`,
`execute_tool`, and `retrieval` spans. With auto-instrumentation you get the
OpenLLMetry names instead - `LangGraph.workflow`, `ChatOllama.chat`, and
`execute_task` nodes.
#### Common Issues
| Symptom | Cause and fix |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------- |
| No GenAI spans, only HTTP and DB | Neither path is active, or the handler was never passed to `invoke`. Pass it in `config={"callbacks": [...]}`. |
| Spans appear but mis-nested | Parenting on the ambient context. Parent on the run tree via `parent_run_id`. |
| `gen_ai.request.model` is `unknown` | Auto-instrumentation with Ollama. Use the custom handler, which reads `ls_model_name` from run metadata. |
| Every operation traced twice | Auto-instrumentation and a custom handler are both active. Run exactly one. |
| Metrics missing, spans fine | No `MeterProvider` installed. See [Telemetry Setup](#telemetry-setup). |
| Prompts show up unexpectedly | Auto-instrumentation captures content by default. Set `TRACELOOP_TRACE_CONTENT=false`. |
| No traces reach Scout | Check the collector `oauth2client` credentials and that the SDK points at the collector's OTLP port. |
### Security Considerations
- **Content is off by default** in the custom handler; keep it off in
production unless you have a specific need, and scrub before recording.
- **Auto-instrumentation is the opposite** - set `TRACELOOP_TRACE_CONTENT=false`
in any environment with sensitive prompts.
- **Scrub tool arguments and queries**, not just prompts. Incident text often
carries IPs, hostnames, and tokens; the `scrub` helper handles the common
cases and truncates.
- **Protect collector credentials** with environment variables or a secret
store; never commit `SCOUT_CLIENT_SECRET`.
### Performance Considerations
- **The handler is cheap.** It stores a `run_id -> span` entry and sets
attributes; the cost is dwarfed by the LLM call. Spans export in a
background thread via `BatchSpanProcessor`.
- **Collapse graph node runs** if you do not need `model` / `tools` node
spans - fewer spans per trace, lower export volume.
- **Sample in production.** Head or tail sampling on the collector keeps
trace volume manageable while preserving errors and slow requests.
- **Batch exports** are already configured; tune `send_batch_size` and
`timeout` for your throughput.
### FAQ
#### How does LangChain OpenTelemetry tracing differ from LangSmith?
LangSmith gives deep LangChain-specific traces but sits apart from your HTTP,
database, and infrastructure telemetry. OpenTelemetry produces one trace
across every layer, so a slow `/diagnose` response links to the exact LLM
call, tool invocation, vector query, and SQL `INSERT` that caused it. It is
also vendor-neutral: the same spans flow to base14 Scout or any OTLP backend.
#### Does a LangChain callback handler add latency?
No. Starting and ending a span takes microseconds; LLM calls take seconds.
The handler only stores a `run_id -> span` map and sets attributes, and spans
export in a background thread off the request path.
#### Which LangChain versions does this guide support?
LangChain 1.3+ with `langchain-core` 1.4+, where the agent is built with
`create_agent` and is LangGraph-backed. The callback API has been stable
across the 1.x line.
#### How do I trace tool calls in a LangChain agent?
Implement `on_tool_start` and `on_tool_end` in a callback handler and emit an
`execute_tool` span with `gen_ai.tool.name` and `gen_ai.tool.type`. The
zero-code OpenLLMetry library does this for you; the custom handler here shows
the exact mapping.
#### How do I track LLM cost with LangChain and OpenTelemetry?
Read `usage_metadata` in `on_llm_end` for token counts, multiply by a
per-model price table, and record a `gen_ai.client.cost` counter alongside the
`gen_ai.client.token.usage` histogram. Attach `gen_ai.request.model` and
`gen_ai.provider.name` so you can group cost by model.
#### Can I capture prompts and completions in LangChain traces?
Yes, but it is off by default. The custom handler captures PII-scrubbed
content only when `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`.
OpenLLMetry auto-instrumentation captures content by default and is controlled
by its own `TRACELOOP_TRACE_CONTENT`, so set `TRACELOOP_TRACE_CONTENT=false`
to disable it there.
#### Should I use auto-instrumentation or a custom callback handler?
Start with OpenLLMetry for traces in minutes and no code changes. Move to a
custom handler when you need clean `gen_ai` span names, correct model and
finish-reason fields, custom retrieval attributes, or content off by default.
Both inject a LangChain callback handler.
#### Why does that package come from Traceloop, not OpenTelemetry?
The PyPI name belongs to Traceloop's OpenLLMetry, not the official
OpenTelemetry GenAI SIG. That package is mature and exports to any OTLP
collector. The official SIG instrumentation is distributed from the
`opentelemetry-python-contrib` source tree and is the emerging `gen_ai`-native
standard to track.
#### How do MCP tools fit, and are they traced?
This example uses in-process LangChain tools, which the callback handler
traces as `execute_tool` spans. When tools live behind a Model Context
Protocol server, the OpenTelemetry MCP span conventions capture that hop; that
is a separate integration and no MCP server runs here.
### What's Next?
#### Related Guides
- [LangGraph Instrumentation](./langgraph.md) - Hand-built `StateGraph`
agents with manual node and edge spans.
- [LlamaIndex Instrumentation](./llamaindex.md) - RAG-first framework tracing
with the same GenAI conventions.
- [FastAPI Instrumentation](./fast-api.md) - The HTTP host that carries the
agent endpoint.
- [Python Custom Instrumentation](../custom-instrumentation/python.md) -
Manual spans and advanced patterns.
#### Scout Platform Features
- [Creating Alerts](../../../guides/creating-alerts-with-logx.md) - Alert on
cost spikes, error rates, or slow diagnoses.
- [Dashboard Creation](../../../guides/create-your-first-dashboard.md) - Build
token, cost, and latency dashboards from the GenAI metrics.
#### Deployment and Operations
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) -
Local development with the OpenTelemetry Collector.
### References
- [OpenTelemetry GenAI Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/tree/main/docs/gen-ai)
- [OpenTelemetry Python SDK](https://opentelemetry.io/docs/languages/python/)
- [LangChain Documentation](https://python.langchain.com/)
- [OpenLLMetry (Traceloop)](https://github.com/traceloop/openllmetry)
- [OpenTelemetry Collector Configuration](https://opentelemetry.io/docs/collector/configuration/)
---
## LangGraph OpenTelemetry Instrumentation - AI Agent Monitoring Guide
## LangGraph
Implement OpenTelemetry instrumentation for LangGraph applications to enable
comprehensive AI agent pipeline monitoring, LLM cost tracking, and end-to-end
trace visibility. This guide shows you how to instrument a LangGraph-powered
agent pipeline with custom GenAI semantic convention spans, conditional edge
routing observability, tool-calling node traces, multi-provider LLM support,
token and cost metrics, PII scrubbing, and production deployment with Docker
Compose.
LangGraph is a Python framework for stateful LLM agents, in the same space as
[LlamaIndex](./llamaindex.md) and the TypeScript
[Vercel AI SDK](./vercel-ai-sdk.md).
LangGraph applications present unique observability challenges beyond standard
LLM calls. An agent pipeline involves multiple nodes executing sequentially or
conditionally, each potentially making LLM calls, database queries, or tool
invocations. Without instrumentation, you cannot see which node is slow, which
routing decision was taken, or how much each agent step costs. OpenTelemetry
bridges this gap by letting you wrap every node, edge, and tool call with spans
that carry LangGraph-specific context alongside standard HTTP and database
telemetry.
Whether you're building multi-step agent pipelines, sales automation workflows,
RAG systems with agent orchestration, or any application that uses LangGraph's
StateGraph for complex control flow, this guide provides production-ready
patterns for unified AI agent observability where every node execution, routing
decision, LLM call, and database query lives in a single trace on base14 Scout.
:::tip TL;DR
Install `opentelemetry-sdk` and wrap each LangGraph node with a custom span
using the GenAI semantic conventions. Configure the OTLP exporter to send
traces to base14 Scout, and use span attributes to capture token counts, LLM
costs, and routing decisions at every node and conditional edge.
:::
> **Note:** For general LLM observability patterns applicable to any Python
> framework, see the
> [LLM Observability guide](../../../guides/ai-observability/llm-observability.md).
> This guide focuses specifically on LangGraph integration patterns.
:::note Running this in production
Storing and querying these traces at production volume is what base14 Scout
does.
[Check out Scout LLM Observability](https://base14.io/scout/llm-observability).
:::
### Who This Guide Is For
This documentation is designed for:
- **AI/ML engineers**: building LangGraph agent pipelines and needing visibility
into node performance, routing decisions, and cost
- **Backend developers**: adding agent orchestration to existing FastAPI
applications and wanting unified tracing across all layers
- **Platform teams**: standardizing observability across AI agent services and
traditional microservices
- **Engineering teams**: migrating from LangSmith tracing to OpenTelemetry for
vendor-neutral observability
- **DevOps engineers**: deploying LangGraph applications with production
monitoring, cost alerting, and pipeline health tracking
### Overview
This guide demonstrates how to:
- Set up unified OpenTelemetry for a LangGraph application (traces + metrics +
logs)
- Wrap LangGraph nodes with `wrap_agent` for automatic span creation
- Instrument conditional edge routing with span attributes for routing decisions
- Trace tool-calling nodes with dedicated tool spans
- Create a pipeline-level parent span for aggregate metrics
- Track token usage and calculate cost per LLM call with a pricing table
- Record evaluation metrics for agent output quality tracking
- Scrub PII from prompts and completions before recording in telemetry
- Support multiple LLM providers (Anthropic, OpenAI, Google) through a single
interface
- Deploy with Docker Compose and the OpenTelemetry Collector
### Prerequisites
Before starting, ensure you have:
- **Python 3.12 or later** installed (3.13+ recommended)
- **An LLM API key** from at least one provider (Anthropic, OpenAI, or Google)
- **Scout Collector** configured and accessible
- See [Docker Compose Setup](../../collector-setup/docker-compose-example.md)
for local development
- Basic understanding of OpenTelemetry concepts (traces, spans, metrics)
- Familiarity with LangGraph's StateGraph API
#### Compatibility Matrix
| Component | Minimum Version | Recommended |
| ----------------- | --------------- | ----------- |
| Python | 3.12 | 3.13+ |
| LangGraph | 0.2 | 1.0.6+ |
| langgraph-core | 0.2 | 0.3.38+ |
| langchain-core | 0.3 | 0.3.63+ |
| opentelemetry-sdk | 1.39.0 | 1.39+ |
| opentelemetry-api | 1.39.0 | 1.39+ |
| FastAPI | 0.115+ | 0.128+ |
| SQLAlchemy | 2.0 | 2.0.45+ |
| Anthropic SDK | 0.40+ | 0.76+ |
| OpenAI SDK | 1.0+ | 1.60+ |
| Google GenAI SDK | 1.0+ | 1.59+ |
### Installation
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
```bash showLineNumbers title="Terminal"
pip install \
opentelemetry-api \
opentelemetry-sdk \
opentelemetry-exporter-otlp-proto-http \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-httpx \
opentelemetry-instrumentation-logging \
langgraph langchain-core \
anthropic openai google-genai \
fastapi uvicorn pydantic-settings \
asyncpg sqlalchemy tenacity httpx
```
```mdx-code-block
```
```bash showLineNumbers title="Terminal"
uv add \
opentelemetry-api \
opentelemetry-sdk \
opentelemetry-exporter-otlp-proto-http \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-httpx \
opentelemetry-instrumentation-logging \
langgraph langchain-core \
anthropic openai google-genai \
fastapi uvicorn pydantic-settings \
asyncpg sqlalchemy tenacity httpx
```
```mdx-code-block
```
### Configuration
```mdx-code-block
```
```python showLineNumbers title="src/sales_intelligence/telemetry.py"
import atexit
import logging
import os
from opentelemetry import _logs, metrics, trace
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
OTLPLogExporter,
)
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
OTLPMetricExporter,
)
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
OTLPSpanExporter,
)
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.instrumentation.sqlalchemy import (
SQLAlchemyInstrumentor,
)
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import (
PeriodicExportingMetricReader,
)
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
def setup_telemetry(
service_name: str,
otlp_endpoint: str,
engine=None,
) -> tuple[trace.Tracer, metrics.Meter]:
"""Initialize unified observability: traces, metrics, logs.
Auto-instruments HTTP, database, and httpx layers.
LangGraph agent spans and GenAI spans are handled by
custom instrumentation in graph.py and llm.py.
"""
if os.environ.get("OTEL_SDK_DISABLED") == "true":
return (
trace.get_tracer(service_name),
metrics.get_meter(service_name),
)
resource = Resource.create({
"service.name": service_name,
"service.version": os.getenv("SERVICE_VERSION", "1.0.0"),
"deployment.environment": os.getenv(
"SCOUT_ENVIRONMENT", "development"
),
"environment": os.getenv(
"SCOUT_ENVIRONMENT", "development"
),
})
trace_provider = TracerProvider(resource=resource)
trace_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(
endpoint=f"{otlp_endpoint}/v1/traces"
)
)
)
trace.set_tracer_provider(trace_provider)
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(
endpoint=f"{otlp_endpoint}/v1/metrics"
),
export_interval_millis=10000,
)
metric_provider = MeterProvider(
resource=resource, metric_readers=[metric_reader]
)
metrics.set_meter_provider(metric_provider)
log_provider = LoggerProvider(resource=resource)
log_provider.add_log_record_processor(
BatchLogRecordProcessor(
OTLPLogExporter(
endpoint=f"{otlp_endpoint}/v1/logs"
)
)
)
_logs.set_logger_provider(log_provider)
logging.getLogger().addHandler(
LoggingHandler(
level=logging.INFO, logger_provider=log_provider
)
)
atexit.register(trace_provider.shutdown)
atexit.register(metric_provider.shutdown)
atexit.register(log_provider.shutdown)
HTTPXClientInstrumentor().instrument()
LoggingInstrumentor().instrument(set_logging_format=True)
if engine:
SQLAlchemyInstrumentor().instrument(
engine=engine.sync_engine
)
return (
trace.get_tracer(service_name),
metrics.get_meter(service_name),
)
def instrument_fastapi(app) -> None:
from opentelemetry.instrumentation.fastapi import (
FastAPIInstrumentor,
)
FastAPIInstrumentor.instrument_app(
app,
excluded_urls="health",
exclude_spans=["receive", "send"],
)
```
```mdx-code-block
```
```python showLineNumbers title="src/sales_intelligence/config.py"
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
service_name: str = "ai-sales-intelligence"
database_url: str = (
"postgresql+asyncpg://postgres:postgres"
"@localhost:5432/sales"
)
llm_provider: str = "anthropic"
llm_model: str = "claude-sonnet-4-20250514"
llm_temperature: float = 0.7
llm_timeout: float = 30.0
anthropic_api_key: str = ""
openai_api_key: str = ""
google_api_key: str = ""
fallback_provider: str = ""
fallback_model: str = ""
score_threshold: int = 50
quality_threshold: int = 60
request_timeout: float = 120.0
otlp_endpoint: str = "http://otel-collector:4318"
otel_sdk_disabled: bool = False
scout_environment: str = "development"
host: str = "0.0.0.0"
port: int = 8000
@lru_cache
def get_settings() -> Settings:
return Settings()
```
```mdx-code-block
```
For container deployments where configuration is managed externally:
```bash showLineNumbers title=".env"
# Application
SERVICE_NAME=ai-sales-intelligence
DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/sales
LLM_PROVIDER=anthropic
LLM_MODEL=claude-sonnet-4-20250514
LLM_TEMPERATURE=0.7
LLM_TIMEOUT=30.0
REQUEST_TIMEOUT=120.0
HOST=0.0.0.0
PORT=8000
# Agent Pipeline
SCORE_THRESHOLD=50
QUALITY_THRESHOLD=60
# LLM Provider Keys
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=
GOOGLE_API_KEY=
# Fallback Provider (optional)
FALLBACK_PROVIDER=openai
FALLBACK_MODEL=gpt-4.1-mini
# OpenTelemetry
OTLP_ENDPOINT=http://otel-collector:4318
OTEL_SDK_DISABLED=false
SCOUT_ENVIRONMENT=production
```
The Pydantic `Settings` class reads all environment variables automatically (see
the Pydantic Settings tab). No code changes needed - set the variables and the
application picks them up.
```mdx-code-block
```
### Production Configuration
#### OpenTelemetry Collector
```yaml showLineNumbers title="otel-collector-config.yaml"
extensions:
health_check:
endpoint: 0.0.0.0:13133
oauth2client:
client_id: ${SCOUT_CLIENT_ID}
client_secret: ${SCOUT_CLIENT_SECRET}
token_url: ${SCOUT_TOKEN_URL}
endpoint_params:
audience: b14collector
timeout: 10s
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
batch:
timeout: 10s
send_batch_size: 1024
attributes:
actions:
- key: deployment.environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
- key: environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
exporters:
otlp_http/b14:
endpoint: ${SCOUT_ENDPOINT}
auth:
authenticator: oauth2client
compression: gzip
timeout: 30s
retry_on_failure:
enabled: true
debug:
verbosity: basic
service:
extensions: [health_check, oauth2client]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlp_http/b14, debug]
metrics:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlp_http/b14, debug]
logs:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlp_http/b14, debug]
```
#### Docker Compose
```yaml showLineNumbers title="compose.yml"
services:
app:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/sales
- OTLP_ENDPOINT=http://otel-collector:4318
- OTEL_SDK_DISABLED=false
- LLM_PROVIDER=${LLM_PROVIDER:-anthropic}
- LLM_MODEL=${LLM_MODEL:-claude-sonnet-4-20250514}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- SCOUT_ENVIRONMENT=${SCOUT_ENVIRONMENT:-development}
depends_on:
postgres:
condition: service_healthy
otel-collector:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 60s
timeout: 5s
retries: 3
postgres:
image: postgres:18
environment:
POSTGRES_DB: sales
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
otel-collector:
image: otel/opentelemetry-collector-contrib:0.144.0
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
ports:
- "4317:4317"
- "4318:4318"
- "13133:13133"
environment:
- SCOUT_CLIENT_ID=${SCOUT_CLIENT_ID:-}
- SCOUT_CLIENT_SECRET=${SCOUT_CLIENT_SECRET:-}
- SCOUT_TOKEN_URL=${SCOUT_TOKEN_URL:-https://auth.base14.io/oauth/token}
- SCOUT_ENDPOINT=${SCOUT_ENDPOINT:-https://collector.base14.io}
- SCOUT_ENVIRONMENT=${SCOUT_ENVIRONMENT:-development}
```
#### Dockerfile
```dockerfile showLineNumbers title="Dockerfile"
FROM python:3.13-slim
WORKDIR /app
RUN pip install --no-cache-dir uv && \
apt-get update && apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --no-dev
COPY src/ src/
ENV PYTHONPATH=/app/src
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "sales_intelligence.main:app", \
"--host", "0.0.0.0", "--port", "8000"]
```
### Framework-Specific Features
This section covers LangGraph-specific instrumentation patterns that go beyond
generic LLM observability. These patterns give you visibility into the agent
orchestration layer - which nodes executed, what routing decisions were made,
how state flowed through the pipeline, and where time was spent.
#### State Definition
Define the pipeline state as a TypedDict. LangGraph passes this state object
between nodes, and each node returns updates to merge back:
```python showLineNumbers title="src/sales_intelligence/state.py"
from dataclasses import dataclass, field
from typing import TypedDict
@dataclass
class Prospect:
name: str
title: str
company: str
score: int = 0
enrichment: str = ""
draft: str = ""
@dataclass
class Evaluation:
prospect_id: str
quality_score: int
passed: bool
feedback: str = ""
class AgentState(TypedDict, total=False):
campaign_id: str
target_keywords: list[str]
target_titles: list[str]
score_threshold: int
quality_threshold: int
prospects: list[Prospect]
drafts: list[str]
evaluations: list[Evaluation]
errors: list[str]
```
#### Node Instrumentation with `wrap_agent`
Create a wrapper function that adds an OTel span around each LangGraph node.
Every node execution becomes a child span of the pipeline span, carrying the
agent name and business context:
```python showLineNumbers title="src/sales_intelligence/graph.py"
from opentelemetry import trace
from opentelemetry.trace import StatusCode
tracer = trace.get_tracer("gen_ai.agent")
def wrap_agent(name, agent_fn, needs_session=False):
"""Wrap a LangGraph node function with an OTel span.
Each node receives the full AgentState and returns
a partial dict to merge back into the state.
"""
async def wrapped(state, config=None):
with tracer.start_as_current_span(
f"invoke_agent {name}"
) as span:
span.set_attribute(
"gen_ai.operation.name", "invoke_agent"
)
span.set_attribute("gen_ai.agent.name", name)
span.set_attribute(
"campaign_id", state.get("campaign_id", "")
)
try:
if needs_session:
result = await agent_fn(state, session)
else:
result = await agent_fn(state)
errors = result.get("errors", [])
span.set_attribute(
"agent.errors_count", len(errors)
)
return result
except Exception as e:
span.record_exception(e)
span.set_status(
StatusCode.ERROR, str(e)
)
raise
return wrapped
```
#### Conditional Edge Routing
LangGraph's conditional edges let you branch the pipeline based on state.
Instrument the routing function to record which path was taken and why:
```python showLineNumbers title="src/sales_intelligence/graph.py"
def route_after_score(state: AgentState) -> str:
"""Route based on whether any prospects passed scoring.
Records the routing decision as span attributes so you
can see in traces why a pipeline skipped drafting.
"""
span = trace.get_current_span()
prospects = state.get("prospects", [])
threshold = state.get("score_threshold", 50)
qualified = [
p for p in prospects if p.score >= threshold
]
span.set_attribute(
"routing.total_prospects", len(prospects)
)
span.set_attribute(
"routing.qualified_count", len(qualified)
)
span.set_attribute(
"routing.threshold", threshold
)
if qualified:
span.set_attribute("routing.decision", "draft")
return "draft"
span.set_attribute("routing.decision", "end")
return "end"
```
#### Building the Pipeline with Conditional Edges
```python showLineNumbers title="src/sales_intelligence/graph.py"
from langgraph.graph import END, START, StateGraph
def create_pipeline(session):
"""Create instrumented LangGraph pipeline."""
graph = StateGraph(AgentState)
graph.add_node(
"research",
wrap_agent(
"research", research_agent,
needs_session=True,
),
)
graph.add_node(
"enrich",
wrap_agent("enrich", enrich_agent),
)
graph.add_node(
"score",
wrap_agent("score", score_agent),
)
graph.add_node(
"draft",
wrap_agent("draft", draft_agent),
)
graph.add_node(
"evaluate",
wrap_agent("evaluate", evaluate_agent),
)
graph.add_edge(START, "research")
graph.add_edge("research", "enrich")
graph.add_edge("enrich", "score")
graph.add_conditional_edges(
"score",
route_after_score,
{"draft": "draft", "end": END},
)
graph.add_edge("draft", "evaluate")
graph.add_edge("evaluate", END)
return graph.compile()
```
#### Tool-Calling Nodes
When agent nodes invoke tools (database searches, API calls, calculations), wrap
each tool invocation with a dedicated span:
```python showLineNumbers title="src/sales_intelligence/agents/research.py"
from opentelemetry import trace
tracer = trace.get_tracer("gen_ai.agent")
async def search_prospects(
session, keywords: list[str], titles: list[str],
) -> list[dict]:
"""Database search tool with OTel instrumentation."""
with tracer.start_as_current_span(
"tool.search_prospects"
) as span:
span.set_attribute(
"gen_ai.operation.name", "tool"
)
span.set_attribute(
"tool.name", "search_prospects"
)
span.set_attribute(
"tool.keywords_count", len(keywords)
)
span.set_attribute(
"tool.titles_count", len(titles)
)
query = build_search_query(keywords, titles)
results = await session.execute(query)
rows = results.fetchall()
span.set_attribute(
"tool.results_count", len(rows)
)
return [dict(r._mapping) for r in rows]
async def research_agent(state, session):
"""Research agent: finds prospects via database search."""
prospects = await search_prospects(
session,
state["target_keywords"],
state["target_titles"],
)
return {
"prospects": [
Prospect(
name=p["name"],
title=p["title"],
company=p["company"],
)
for p in prospects
]
}
```
#### Pipeline-Level Parent Span
Wrap the entire pipeline run in a parent span to capture aggregate metrics. All
node spans become children of this span:
```python showLineNumbers title="src/sales_intelligence/graph.py"
async def run_pipeline(
campaign_id: str,
target_keywords: list[str],
target_titles: list[str],
session,
score_threshold: int = 50,
quality_threshold: int = 60,
):
"""Run the agent pipeline with a top-level span."""
with tracer.start_as_current_span(
"pipeline.run"
) as span:
span.set_attribute("campaign_id", campaign_id)
span.set_attribute(
"target_keywords", target_keywords
)
span.set_attribute(
"pipeline.score_threshold", score_threshold
)
span.set_attribute(
"pipeline.quality_threshold",
quality_threshold,
)
initial_state = AgentState(
campaign_id=campaign_id,
target_keywords=target_keywords,
target_titles=target_titles,
score_threshold=score_threshold,
quality_threshold=quality_threshold,
)
pipeline = create_pipeline(session)
result = await pipeline.ainvoke(initial_state)
span.set_attribute(
"pipeline.prospects_found",
len(result.get("prospects", [])),
)
span.set_attribute(
"pipeline.drafts_generated",
len(result.get("drafts", [])),
)
evaluations = result.get("evaluations", [])
span.set_attribute(
"pipeline.evaluations_passed",
sum(1 for e in evaluations if e.passed),
)
return result
```
The resulting trace looks like this:
```text showLineNumbers title="Unified trace for POST /campaigns/{id}/run"
POST /campaigns/{id}/run 8.4s [auto: FastAPI]
├─ db.query SELECT connections 12ms [auto: SQLAlchemy]
├─ pipeline.run 8.3s [custom: pipeline]
│ ├─ invoke_agent research 80ms [custom: agent]
│ │ └─ tool.search_prospects 45ms [custom: tool]
│ │ └─ db.query SELECT ... tsvector 40ms [auto: SQLAlchemy]
│ ├─ invoke_agent enrich 2.1s [custom: agent]
│ │ └─ gen_ai.chat claude-sonnet-4 2.0s [custom: LLM]
│ │ └─ HTTP POST api.anthropic.com 1.9s [auto: httpx]
│ ├─ invoke_agent score 1.8s [custom: agent]
│ │ └─ gen_ai.chat claude-sonnet-4 1.7s [custom: LLM]
│ │ └─ HTTP POST api.anthropic.com 1.7s [auto: httpx]
│ ├─ invoke_agent draft 3.2s [custom: agent]
│ │ └─ gen_ai.chat claude-sonnet-4 3.1s [custom: LLM]
│ │ └─ HTTP POST api.anthropic.com 3.1s [auto: httpx]
│ └─ invoke_agent evaluate 1.1s [custom: agent]
│ └─ gen_ai.chat claude-sonnet-4 1.0s [custom: LLM]
│ └─ HTTP POST api.anthropic.com 0.9s [auto: httpx]
└─ db.query INSERT prospects 8ms [auto: SQLAlchemy]
```
#### Multi-Provider LLM Factory
Support multiple LLM providers through a single interface. Each provider's API
calls are automatically captured by the httpx auto-instrumentor, while custom
GenAI spans add model-specific context:
```python showLineNumbers title="src/sales_intelligence/llm.py"
from anthropic import AsyncAnthropic
from openai import AsyncOpenAI
from google import genai
PROVIDER_SERVERS = {
"anthropic": "api.anthropic.com",
"openai": "api.openai.com",
"gcp.gemini": "generativelanguage.googleapis.com",
}
async def call_provider(
provider: str,
model: str,
system: str,
prompt: str,
temperature: float = 0.7,
max_tokens: int = 1024,
):
if provider == "anthropic":
client = AsyncAnthropic()
response = await client.messages.create(
model=model,
max_tokens=max_tokens,
temperature=temperature,
system=system,
messages=[
{"role": "user", "content": prompt}
],
)
content = response.content[0].text
return LLMResponse(
content=content,
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
model=response.model,
)
if provider == "openai":
client = AsyncOpenAI()
response = await client.chat.completions.create(
model=model,
max_tokens=max_tokens,
temperature=temperature,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": prompt},
],
)
choice = response.choices[0]
return LLMResponse(
content=choice.message.content or "",
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
model=response.model,
)
raise ValueError(f"Unknown provider: {provider!r}")
```
For detailed provider-specific handling including Google Gemini, see the
[LLM Observability guide](../../../guides/ai-observability/llm-observability.md#custom-llm-instrumentation).
### Custom Manual Instrumentation
#### GenAI Span Attributes
Create LLM spans following OpenTelemetry GenAI semantic conventions. Each LLM
call within an agent node becomes a child span with model, token, and cost
attributes:
```python showLineNumbers title="src/sales_intelligence/llm.py"
import time
from opentelemetry import trace
from opentelemetry.trace import StatusCode
tracer = trace.get_tracer("gen_ai.client")
async def generate(
prompt: str,
system: str,
model: str,
provider: str,
agent_name: str | None = None,
campaign_id: str | None = None,
) -> str:
"""Generate LLM completion with GenAI span."""
server_address = PROVIDER_SERVERS.get(provider, "")
with tracer.start_as_current_span(
f"gen_ai.chat {model}"
) as span:
span.set_attribute(
"gen_ai.operation.name", "chat"
)
span.set_attribute(
"gen_ai.provider.name", provider
)
span.set_attribute(
"gen_ai.request.model", model
)
span.set_attribute(
"gen_ai.request.temperature", 0.7
)
if server_address:
span.set_attribute(
"server.address", server_address
)
if agent_name:
span.set_attribute(
"gen_ai.agent.name", agent_name
)
if campaign_id:
span.set_attribute(
"campaign_id", campaign_id
)
start = time.perf_counter()
try:
response = await call_provider(
provider, model, system, prompt
)
duration = time.perf_counter() - start
span.set_attribute(
"gen_ai.response.model", response.model
)
span.set_attribute(
"gen_ai.usage.input_tokens",
response.input_tokens,
)
span.set_attribute(
"gen_ai.usage.output_tokens",
response.output_tokens,
)
_record_token_metrics(
response, model, provider,
agent_name, campaign_id, duration,
)
return response.content
except Exception as e:
span.record_exception(e)
span.set_status(StatusCode.ERROR, str(e))
span.set_attribute(
"error.type", type(e).__name__
)
error_counter.add(1, {
"gen_ai.request.model": model,
"gen_ai.provider.name": provider,
"error.type": type(e).__name__,
})
raise
```
#### Custom GenAI Metrics
Define metrics following OpenTelemetry GenAI semantic conventions:
```python showLineNumbers title="src/sales_intelligence/llm.py"
from opentelemetry import metrics
meter = metrics.get_meter("gen_ai.client")
token_usage = meter.create_histogram(
name="gen_ai.client.token.usage",
description="Tokens used per LLM call",
unit="{token}",
)
operation_duration = meter.create_histogram(
name="gen_ai.client.operation.duration",
description="Duration of GenAI operations",
unit="s",
)
cost_counter = meter.create_counter(
name="gen_ai.client.cost",
description="Cost of GenAI operations in USD",
unit="usd",
)
error_counter = meter.create_counter(
name="gen_ai.client.error.count",
description="GenAI operation errors",
unit="1",
)
```
#### Token and Cost Tracking
Define pricing per model and record cost metrics with business context for
attribution by agent and campaign:
```python showLineNumbers title="src/sales_intelligence/llm.py"
MODEL_PRICING = {
"claude-opus-4-20250514": {
"input": 15.0, "output": 75.0,
},
"claude-sonnet-4-20250514": {
"input": 3.0, "output": 15.0,
},
"claude-haiku-3-5-20241022": {
"input": 0.80, "output": 4.0,
},
"gpt-4o": {"input": 2.50, "output": 10.0},
"gpt-4.1-mini": {"input": 0.40, "output": 1.60},
}
def calculate_cost(
model: str, input_tokens: int, output_tokens: int,
) -> float:
pricing = MODEL_PRICING.get(
model, {"input": 0.0, "output": 0.0}
)
return (
input_tokens * pricing["input"]
+ output_tokens * pricing["output"]
) / 1_000_000
def _record_token_metrics(
response, model, provider,
agent_name, campaign_id, duration,
):
base_attrs = {
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": provider,
"gen_ai.request.model": model,
}
token_usage.record(
response.input_tokens,
{**base_attrs, "gen_ai.token.type": "input"},
)
token_usage.record(
response.output_tokens,
{**base_attrs, "gen_ai.token.type": "output"},
)
operation_duration.record(duration, base_attrs)
cost = calculate_cost(
model,
response.input_tokens,
response.output_tokens,
)
cost_attrs = {**base_attrs}
if agent_name:
cost_attrs["gen_ai.agent.name"] = agent_name
if campaign_id:
cost_attrs["campaign_id"] = campaign_id
cost_counter.add(cost, cost_attrs)
```
#### Evaluation Metrics
Track agent output quality as OpenTelemetry metrics and span events:
```python showLineNumbers title="src/sales_intelligence/agents/evaluate.py"
from opentelemetry import metrics, trace
tracer = trace.get_tracer("gen_ai.evaluation")
meter = metrics.get_meter("gen_ai.evaluation")
evaluation_score = meter.create_histogram(
name="gen_ai.evaluation.score",
description="Quality evaluation scores",
unit="1",
)
async def evaluate_agent(state):
"""Evaluate draft quality with OTel events."""
evaluations = []
for draft in state.get("drafts", []):
with tracer.start_as_current_span(
"evaluate.draft"
) as span:
score = await run_quality_check(draft)
threshold = state.get(
"quality_threshold", 60
)
passed = score >= threshold
span.set_attribute("quality_score", score)
span.set_attribute("passed", passed)
span.add_event(
"gen_ai.evaluation.result",
attributes={
"gen_ai.evaluation.name": (
"email_quality"
),
"gen_ai.evaluation.score.value": (
score
),
"gen_ai.evaluation.score.label": (
"passed" if passed else "failed"
),
},
)
evaluation_score.record(
score / 100.0,
{
"gen_ai.evaluation.name": (
"email_quality"
),
"campaign_id": state.get(
"campaign_id", ""
),
},
)
evaluations.append(
Evaluation(
prospect_id=draft.prospect_id,
quality_score=score,
passed=passed,
)
)
return {"evaluations": evaluations}
```
#### PII Scrubbing
Scrub PII from all content before recording in span events:
```python showLineNumbers title="src/sales_intelligence/pii.py"
import re
_PII_PATTERNS = [
(re.compile(
r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"
), "[EMAIL]"),
(re.compile(
r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b"
), "[PHONE]"),
(re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "[SSN]"),
(re.compile(
r"https?://(?:www\.)?linkedin\.com/in/[\w-]+"
), "[LINKEDIN]"),
]
def scrub_pii(text: str) -> str:
for pattern, replacement in _PII_PATTERNS:
text = pattern.sub(replacement, text)
return text
```
#### Error Handling with Trace IDs
Include trace IDs in API error responses so users can reference them when
reporting issues:
```python showLineNumbers title="src/sales_intelligence/main.py"
from fastapi import Request
from fastapi.responses import JSONResponse
from opentelemetry import trace
@app.exception_handler(Exception)
async def global_error_handler(
request: Request, exc: Exception,
):
span = trace.get_current_span()
trace_id = span.get_span_context().trace_id
trace_id_hex = format(trace_id, "032x")
return JSONResponse(
status_code=500,
content={
"error": "Internal server error",
"trace_id": trace_id_hex,
},
)
```
### Running Your Application
```mdx-code-block
```
```bash showLineNumbers
uv run uvicorn sales_intelligence.main:app \
--reload --host 0.0.0.0 --port 8000
```
```mdx-code-block
```
```bash showLineNumbers
OTEL_SDK_DISABLED=false \
LLM_PROVIDER=anthropic \
LLM_MODEL=claude-sonnet-4-20250514 \
OTLP_ENDPOINT=http://collector:4318 \
uv run uvicorn sales_intelligence.main:app \
--host 0.0.0.0 --port 8000
```
```mdx-code-block
```
```bash showLineNumbers
docker compose up --build
curl http://localhost:8000/health
docker compose down
```
```mdx-code-block
```
### Troubleshooting
#### Verify Telemetry Is Working
```bash showLineNumbers
curl http://localhost:13133
```
#### Enable Debug Mode
```python showLineNumbers
import logging
logging.getLogger("opentelemetry").setLevel(logging.DEBUG)
```
#### Common Issues
##### Issue: No traces appearing in Scout
**Solutions:**
1. Confirm the OTel Collector is running: `curl http://localhost:13133`
2. Check collector logs: `docker compose logs otel-collector`
3. Verify `OTLP_ENDPOINT` points to the collector, not directly to Scout
4. Ensure `SCOUT_CLIENT_ID` and `SCOUT_CLIENT_SECRET` are set in the collector
environment
##### Issue: Token counts are zero
**Solutions:**
1. Check your LLM SDK version - older versions may not expose `usage` on the
response object
2. Verify the provider response has `input_tokens` and `output_tokens` (naming
varies by provider)
3. For Google GenAI, check `response.usage_metadata` instead of `response.usage`
##### Issue: Agent spans not nested under pipeline span
**Solutions:**
1. Ensure `wrap_agent` creates spans inside the pipeline span context - call
`pipeline.ainvoke()` within the `pipeline.run` span (see
[Pipeline-Level Parent Span](#pipeline-level-parent-span))
2. Verify `setup_telemetry()` is called **before** creating the FastAPI app
3. Check that async context propagation is working - LangGraph preserves the
OTel context across `await` boundaries
##### Issue: Cost metrics not accurate
**Solutions:**
1. Verify your `MODEL_PRICING` dictionary contains the exact model ID string
returned by the provider (e.g., `claude-sonnet-4-20250514`, not
`claude-sonnet-4`)
2. Check that cost is calculated with `/1_000_000` (pricing is per million
tokens)
##### Issue: Conditional edge routing not visible in traces
**Solutions:**
1. Ensure the routing function reads the current span with
`trace.get_current_span()` and sets routing attributes
2. Verify the routing function is called within the span context of the
preceding node
### Security Considerations
#### Protecting Sensitive Data
- **Never record raw prompts** that may contain user data, API keys, or
credentials in span attributes or events
- **Truncate content** to 500 characters to avoid oversized spans
- **Disable content capture** in production if compliance requires it - set
`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=false`
- **Scrub PII** before recording any content in telemetry (see
[PII Scrubbing](#pii-scrubbing) for the regex patterns used)
#### SQL Query Obfuscation
The SQLAlchemy auto-instrumentor captures SQL statements by default. For
sensitive queries, disable enhanced reporting:
```python showLineNumbers
SQLAlchemyInstrumentor().instrument(
engine=engine.sync_engine,
enable_commenter=False,
)
```
#### Compliance Considerations
For applications handling regulated data (GDPR, HIPAA, PCI-DSS):
- Use opt-in content capture - disabled by default in this guide
- Record only token counts and model metadata, not prompt content
- Audit span attributes regularly for sensitive data leaks
- Use the OTel Collector `attributes` processor to redact fields before export
if additional filtering is needed
### Performance Considerations
OpenTelemetry overhead is negligible relative to LLM API latency. A typical LLM
call takes 1-5 seconds; span creation adds microseconds.
#### Optimization Strategies
##### 1. Use BatchSpanProcessor
```python showLineNumbers
trace_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint=endpoint),
max_queue_size=2048,
max_export_batch_size=512,
)
)
```
##### 2. Truncate Content Events
Always truncate prompts and completions to keep span sizes reasonable:
```python showLineNumbers
scrub_pii(prompt)[:500]
```
##### 3. Disable Content Capture in High-Volume Scenarios
```bash showLineNumbers
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=false
```
##### 4. Skip Health Check Endpoints
Exclude high-frequency health checks from tracing:
```python showLineNumbers
FastAPIInstrumentor.instrument_app(
app, excluded_urls="health,metrics"
)
```
### FAQ
#### Does OpenTelemetry add latency to LLM calls?
No. Span creation takes microseconds. LLM API calls take seconds. The overhead
is unmeasurable. `BatchSpanProcessor` exports spans in a background thread.
#### How does this differ from LangSmith tracing?
LangSmith provides deep LangGraph-specific tracing but operates in isolation
from your HTTP and database telemetry. OpenTelemetry gives you a single trace
that spans all layers - you can see that a slow HTTP response was caused by a
specific agent node making an LLM call, and that the same request also ran
database queries. LangSmith cannot show that correlation.
#### Which LangGraph versions are supported?
This guide supports LangGraph 0.2+ and recommends 1.0.6+. The `StateGraph` API
and `add_conditional_edges` have been stable since 0.2. The `wrap_agent` pattern
works with any version that supports async node functions.
#### How do I trace conditional routing in LangGraph with OpenTelemetry?
Use `trace.get_current_span()` inside your routing function to record attributes
like `routing.decision` and `routing.qualified_count`. See
[Conditional Edge Routing](#conditional-edge-routing) for the full pattern.
#### How do I track cost across multiple providers?
Use the `gen_ai.client.cost` counter metric with `gen_ai.provider.name` and
`gen_ai.request.model` attributes. Define pricing per model and calculate from
token counts. This enables `sum(gen_ai.client.cost) by (gen_ai.agent.name)` in
your dashboards.
#### Can I see prompts and completions in traces?
Yes, if you set `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`.
Content is PII-scrubbed and truncated to 500 characters. Disable in production
for compliance.
#### How do I add and trace a new agent node in LangGraph?
Create the agent function, wrap it with `wrap_agent("name", fn)`, add the node
to the `StateGraph` with `graph.add_node()`, and connect it with `add_edge` or
`add_conditional_edges`. The `wrap_agent` wrapper automatically handles span
creation.
#### How does OpenTelemetry trace propagation work in LangGraph subgraphs?
LangGraph subgraphs execute within the same Python async context. OpenTelemetry
automatically propagates the trace context across `await` boundaries, so
subgraph node spans appear as children of the parent graph's span without
additional configuration.
#### How do I trace tool calls in a LangGraph agent with OpenTelemetry?
Wrap each tool invocation with a dedicated span using
`tracer.start_as_current_span("tool.")`. Set `gen_ai.operation.name` to
`"tool"` and `tool.name` to the specific tool. See
[Tool-Calling Nodes](#tool-calling-nodes) for the full pattern.
#### Can I use this with LangChain alongside LangGraph?
Yes. LangGraph builds on top of `langchain-core`. The `wrap_agent` pattern works
regardless of whether your node functions use LangChain components internally.
The GenAI spans capture the LLM calls at the provider SDK level, not the
framework level.
### What's Next?
#### Related Guides
- [FastAPI Instrumentation](./fast-api.md) - Common API host for agents
- [Python Custom Instrumentation](../custom-instrumentation/python.md) - Manual
spans and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
#### Scout Platform Features
- [Creating Alerts](../../../guides/creating-alerts-with-logx.md) - Alert on
cost spikes, error rates, or quality degradation
- [Dashboard Creation](../../../guides/create-your-first-dashboard.md) - Build
dashboards for token usage, cost attribution, and evaluation scores
#### Deployment and Operations
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) -
Local development with the OTel Collector
### Complete Example
#### Project Structure
```text showLineNumbers
ai-sales-intelligence/
├── src/sales_intelligence/
│ ├── main.py # FastAPI app with lifespan
│ ├── config.py # Pydantic Settings
│ ├── telemetry.py # OTel initialization (auto + custom)
│ ├── state.py # AgentState TypedDict
│ ├── llm.py # LLM client with GenAI spans
│ ├── graph.py # LangGraph pipeline with agent spans
│ ├── pii.py # PII scrubbing for telemetry
│ ├── agents/
│ │ ├── research.py # Database search agent
│ │ ├── enrich.py # LLM enrichment agent
│ │ ├── score.py # LLM scoring agent
│ │ ├── draft.py # LLM email draft agent
│ │ └── evaluate.py # LLM quality evaluation agent
│ └── middleware/
│ └── metrics.py # HTTP request metrics
├── otel-collector-config.yaml
├── compose.yml
├── Dockerfile
└── pyproject.toml
```
#### Key Files
| File | Demonstrates |
| -------------- | --------------------------------------------------- |
| `telemetry.py` | OTel setup (traces + metrics + logs) |
| `llm.py` | GenAI spans, token/cost metrics, retry/fallback |
| `graph.py` | LangGraph pipeline, `wrap_agent`, conditional edges |
| `state.py` | TypedDict state flowing through nodes |
| `evaluate.py` | Evaluation events and quality metrics |
| `research.py` | Tool-calling node with database search |
| `pii.py` | PII scrubbing before telemetry recording |
| `config.py` | Provider-agnostic settings with Pydantic |
| `compose.yml` | Docker deployment with OTel Collector |
#### GitHub Repository
For a complete working example, see the
[AI Sales Intelligence](https://github.com/base-14/examples/tree/main/python/ai-sales-intelligence)
repository.
### References
- [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/)
- [OpenTelemetry Python SDK](https://opentelemetry.io/docs/languages/python/)
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/)
- [OpenTelemetry Collector Configuration](https://opentelemetry.io/docs/collector/configuration/)
---
## Laravel OpenTelemetry Instrumentation - Eloquent, Queues & HTTP
Implement OpenTelemetry instrumentation for Laravel applications to enable
comprehensive application performance monitoring (APM), distributed tracing, and
observability. This guide shows you how to auto-instrument your Laravel application
to collect traces and metrics from HTTP requests, database queries, background
jobs, cache operations, and custom business logic using the OpenTelemetry PHP SDK.
Laravel is a full-featured PHP framework built on Symfony components.
[Symfony](./symfony.md) is the underlying toolkit, and [Slim](./slim.md) is a
lighter micro-framework alternative.
Laravel applications benefit from automatic instrumentation of popular frameworks
and libraries including Eloquent ORM, HTTP client requests (Guzzle), Redis, queue
workers, and dozens of commonly used packages. With OpenTelemetry, you can monitor
production performance, debug slow requests, trace distributed transactions across
microservices, and identify database bottlenecks without significant code changes.
Whether you're implementing observability for the first time, migrating from
commercial APM solutions, or troubleshooting performance issues in production,
this guide provides production-ready configurations and best practices for
Laravel OpenTelemetry instrumentation.
> **Note:** This guide provides a practical Laravel-focused overview based on the
> official OpenTelemetry documentation. For complete PHP language information,
> please consult the [official OpenTelemetry PHP documentation](https://opentelemetry.io/docs/languages/php/).
:::tip TL;DR
Install the OpenTelemetry PHP extension and SDK packages via Composer, then
register the `OtelSdkServiceProvider` in your Laravel app. HTTP requests,
Eloquent queries, Redis, and queue jobs are traced automatically - no
per-route or per-model code changes required. Export traces to base14 Scout
via OTLP.
:::
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Who This Guide Is For
This documentation is designed for:
- **Laravel developers**: implementing observability and distributed tracing
for the first time
- **DevOps engineers**: deploying Laravel applications with production
monitoring requirements
- **Engineering teams**: migrating from DataDog, New Relic, or other
commercial APM solutions
- **Developers**: debugging performance issues, slow database queries, or N+1
problems in Laravel applications
- **Platform teams**: standardizing observability across multiple Laravel
services
### Overview
This comprehensive guide demonstrates how to:
- Install and configure OpenTelemetry SDK and PHP extension for Laravel
applications
- Set up automatic instrumentation for HTTP requests, database queries, and
popular packages
- Configure production-ready telemetry export to Scout Collector
- Implement custom instrumentation for business-critical operations
- Collect and analyze traces, metrics, and performance data
- Deploy instrumented Laravel applications to development, staging, and
production environments
- Troubleshoot common instrumentation issues and optimize performance
- Secure sensitive data in telemetry exports
### Prerequisites
Before starting, ensure you have:
- **PHP 8.0 or later** (PHP 8.1+ recommended for best performance and compatibility)
- For production deployments, PHP 8.2+ is recommended
- JIT support in PHP 8.0+ improves instrumentation performance
- **Laravel 8.0 or later** installed
- Laravel 10.x or 11.x is recommended for optimal OpenTelemetry support
- Laravel 8.x and 9.x are supported but may require additional configuration
- **Composer 2.0+** for dependency management
- **Scout Collector** configured and accessible
- See [Docker Compose Setup](../../collector-setup/docker-compose-example.md)
for local development
- Production deployments should use a dedicated Scout Collector instance
- **Build tools** for compiling the OpenTelemetry PHP extension (gcc, make,
autoconf)
- Basic understanding of OpenTelemetry concepts (traces, spans, attributes)
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version |
|-----------|----------------|---------------------|
| PHP | 8.0.0 | 8.2.0+ |
| Laravel | 8.0.0 | 11.0.0+ |
| Composer | 2.0.0 | 2.7.0+ |
| OpenTelemetry PHP Extension | 1.0.0 | Latest stable |
| OpenTelemetry SDK | 1.0.0 | 1.6+ |
### Installation
#### Step 1: Install OpenTelemetry PHP Extension
The OpenTelemetry PHP extension provides automatic instrumentation capabilities.
##### Install Build Dependencies
```bash showLineNumbers
# Ubuntu/Debian
sudo apt-get install gcc make autoconf
# Alpine Linux (Docker)
apk add --no-cache autoconf build-base
# macOS
xcode-select --install
```
##### Install Extension via PECL
```bash showLineNumbers
pecl install opentelemetry
```
##### Enable Extension in php.ini
Add the extension to your `php.ini` file:
```ini showLineNumbers title="php.ini"
[opentelemetry]
extension=opentelemetry.so
```
##### Verify Installation
```bash showLineNumbers
php -m | grep opentelemetry
```
Expected output:
```plaintext
opentelemetry
```
#### Step 2: Install Required Packages
Install the necessary OpenTelemetry packages via Composer:
```bash showLineNumbers
composer require \
open-telemetry/sdk \
open-telemetry/exporter-otlp \
open-telemetry/opentelemetry-auto-laravel
```
**Optional packages for additional functionality:**
```bash showLineNumbers
# For PSR-18 HTTP client instrumentation (Guzzle, etc.)
composer require open-telemetry/opentelemetry-auto-psr18
# For complete auto-instrumentation (includes all available instrumentations)
composer require open-telemetry/opentelemetry-auto-slim
```
### Configuration
OpenTelemetry Laravel instrumentation can be configured using multiple approaches
depending on your deployment requirements and preferences. Choose the method that
best fits your application architecture.
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
The recommended approach for Laravel is using environment variables. This
provides flexibility and keeps configuration separate from your application code.
#### Configure .env File
```bash showLineNumbers title=".env"
# OpenTelemetry Configuration
OTEL_PHP_AUTOLOAD_ENABLED=true
OTEL_SERVICE_NAME=laravel-app
OTEL_SERVICE_VERSION=1.0.0
OTEL_TRACES_EXPORTER=otlp
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_PROPAGATORS=baggage,tracecontext
```
This configuration automatically instruments all supported Laravel components including:
- **Laravel Core**: HTTP routing, middleware, controllers, views
- **Eloquent ORM**: Database queries, model events
- **HTTP Clients**: Guzzle, PSR-18 clients
- **Databases**: MySQL, PostgreSQL, SQLite, SQL Server
- **Caching**: Redis, Memcached, File cache
- **Queue Workers**: Redis, Database, SQS queues
- **External APIs**: HTTP requests with distributed trace propagation
```mdx-code-block
```
For applications requiring programmatic configuration, create a custom service provider:
```php showLineNumbers title="app/Providers/OpenTelemetryServiceProvider.php"
merge(
ResourceInfo::create(
Attributes::create([
'service.name' => config('app.name'),
'service.version' => config('app.version', '1.0.0'),
'deployment.environment' => config('app.env'),
'environment' => config('app.env'),
])
)
);
// Create transport for OTLP exporter
$transport = (new OtlpHttpTransportFactory())->create(
config('otel.exporter.endpoint', 'http://localhost:4318'),
'application/x-protobuf'
);
$exporter = new SpanExporter($transport);
$tracerProvider = new TracerProvider(
new BatchSpanProcessor($exporter),
null,
$resource
);
Sdk::builder()
->setTracerProvider($tracerProvider)
->build();
}
}
```
Register the service provider in `config/app.php`:
```php showLineNumbers title="config/app.php"
'providers' => [
// Other Service Providers
App\Providers\OpenTelemetryServiceProvider::class,
],
```
```mdx-code-block
```
For early initialization, configure OpenTelemetry in your bootstrap files:
```php showLineNumbers title="bootstrap/app.php"
```
For containerized deployments, configure environment variables in your
Dockerfile or docker-compose.yml:
```yaml showLineNumbers title="docker-compose.yml"
version: '3.8'
services:
laravel-app:
build: .
environment:
# Application Settings
APP_NAME: laravel-app
APP_ENV: production
# OpenTelemetry Configuration
OTEL_PHP_AUTOLOAD_ENABLED: "true"
OTEL_SERVICE_NAME: laravel-app
OTEL_SERVICE_VERSION: "1.0.0"
OTEL_TRACES_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT: http://scout-collector:4318
OTEL_PROPAGATORS: baggage,tracecontext
depends_on:
- scout-collector
ports:
- "8000:8000"
scout-collector:
image: base14/scout-collector:latest
ports:
- "4318:4318"
```
```mdx-code-block
```
#### Scout Collector Integration
When using Scout Collector, configure your Laravel application to send telemetry
data to the Scout Collector endpoint with OAuth2 authentication:
```bash showLineNumbers title=".env"
# Scout Collector Configuration
OTEL_EXPORTER_OTLP_ENDPOINT=https://your-tenant.base14.io/v1/traces
SCOUT_CLIENT_ID=your_client_id
SCOUT_CLIENT_SECRET=your_client_secret
SCOUT_TOKEN_URL=https://your-tenant.base14.io/oauth/token
# Service Configuration
OTEL_SERVICE_NAME=laravel-app
OTEL_SERVICE_VERSION=1.0.0
OTEL_PHP_AUTOLOAD_ENABLED=true
OTEL_TRACES_EXPORTER=otlp
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
```
> **Scout Dashboard Integration**: After configuration, your traces will appear
> in the Scout Dashboard. Navigate to the Traces section to view request flows,
> identify performance bottlenecks, and analyze distributed transactions across
> your Laravel services.
### Production Configuration
Production deployments require additional configuration for optimal performance,
reliability, and resource utilization. This section covers production-specific
settings and best practices.
#### Production Environment Variables
Create a production-optimized environment configuration:
```bash showLineNumbers title=".env.production"
# Application Settings
APP_NAME=laravel-app-production
APP_ENV=production
APP_DEBUG=false
# OpenTelemetry Service Configuration
OTEL_PHP_AUTOLOAD_ENABLED=true
OTEL_SERVICE_NAME=laravel-app
OTEL_SERVICE_VERSION=2.1.3
OTEL_SERVICE_NAMESPACE=production
# Scout Collector Endpoint
OTEL_EXPORTER_OTLP_ENDPOINT=https://scout-collector.example.com/v1/traces
SCOUT_CLIENT_ID=prod_client_id
SCOUT_CLIENT_SECRET=prod_secret_key
SCOUT_TOKEN_URL=https://scout-collector.example.com/oauth/token
# Exporter Settings
OTEL_TRACES_EXPORTER=otlp
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_COMPRESSION=gzip
OTEL_EXPORTER_OTLP_TIMEOUT=10
# Propagators
OTEL_PROPAGATORS=baggage,tracecontext,b3
# Batch Span Processor Settings (Production Optimized)
OTEL_BSP_MAX_QUEUE_SIZE=2048
OTEL_BSP_SCHEDULE_DELAY_MILLIS=5000
OTEL_BSP_EXPORT_TIMEOUT_MILLIS=30000
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
# Resource Attributes
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=demo,environment=demo,host.name=${HOSTNAME},cloud.provider=aws,cloud.region=us-east-1
```
**Benefits of production configuration:**
- GZIP compression reduces network bandwidth by 70-80%
- Batch processing minimizes network requests by 95%
- Resource attributes enable filtering by environment and infrastructure
- Configurable timeouts prevent hanging exports
#### Docker Production Configuration
For containerized Laravel applications, configure OpenTelemetry in your Docker setup:
```dockerfile showLineNumbers title="Dockerfile"
FROM php:8.2-fpm-alpine
# Install system dependencies
RUN apk add --no-cache \
autoconf \
build-base \
postgresql-dev \
libzip-dev \
zip \
unzip
# Install PHP extensions
RUN docker-php-ext-install pdo pdo_pgsql zip opcache
# Install OpenTelemetry extension
RUN pecl install opentelemetry && \
docker-php-ext-enable opentelemetry
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
# Copy application files
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts
COPY . .
# Generate optimized autoloader
RUN composer dump-autoload --optimize
# Set production environment
ENV APP_ENV=production
ENV OTEL_PHP_AUTOLOAD_ENABLED=true
ENV OTEL_SERVICE_NAME=laravel-app
ENV OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4318
# Optimize Laravel for production
RUN php artisan config:cache && \
php artisan route:cache && \
php artisan view:cache
EXPOSE 8000
CMD ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"]
```
```yaml showLineNumbers title="docker-compose.prod.yml"
version: '3.8'
services:
laravel-app:
build: .
environment:
APP_NAME: laravel-app
APP_ENV: production
APP_DEBUG: "false"
# OpenTelemetry Configuration
OTEL_PHP_AUTOLOAD_ENABLED: "true"
OTEL_SERVICE_NAME: laravel-app
OTEL_SERVICE_VERSION: "${APP_VERSION:-1.0.0}"
OTEL_EXPORTER_OTLP_ENDPOINT: http://scout-collector:4318
OTEL_EXPORTER_OTLP_COMPRESSION: gzip
# Database
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_PORT: 5432
DB_DATABASE: laravel_production
DB_USERNAME: laravel
DB_PASSWORD: "${DB_PASSWORD}"
# Resource Attributes
OTEL_RESOURCE_ATTRIBUTES: "deployment.environment=demo,environment=demo,service.instance.id=${HOSTNAME}"
depends_on:
- postgres
- scout-collector
ports:
- "8000:8000"
networks:
- app-network
scout-collector:
image: base14/scout-collector:latest
ports:
- "4318:4318"
networks:
- app-network
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: laravel_production
POSTGRES_USER: laravel
POSTGRES_PASSWORD: "${DB_PASSWORD}"
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
postgres-data:
```
#### Resource Attributes Configuration
Add rich context to all telemetry data with resource attributes:
```bash showLineNumbers title=".env"
OTEL_RESOURCE_ATTRIBUTES="deployment.environment=development,environment=development,service.namespace=ecommerce,service.instance.id=${HOSTNAME},host.name=${HOSTNAME},host.type=container,cloud.provider=aws,cloud.region=us-east-1,k8s.pod.name=${K8S_POD_NAME},k8s.namespace.name=${K8S_NAMESPACE}"
```
These attributes help you:
- Filter traces by environment, region, or instance
- Correlate issues with specific deployments
- Analyze performance across different infrastructure
- Debug production incidents faster
#### Health Check Implementation
Create health check endpoints to verify telemetry export:
```php showLineNumbers title="routes/api.php"
json([
'status' => 'ok',
'timestamp' => now()->toIso8601String(),
'environment' => config('app.env'),
]);
}
public function telemetry(): JsonResponse
{
$otelEnabled = extension_loaded('opentelemetry');
return response()->json([
'status' => 'ok',
'telemetry' => [
'extension_loaded' => $otelEnabled,
'service_name' => env('OTEL_SERVICE_NAME'),
'service_version' => env('OTEL_SERVICE_VERSION'),
'exporter_endpoint' => env('OTEL_EXPORTER_OTLP_ENDPOINT'),
'php_version' => PHP_VERSION,
'laravel_version' => app()->version(),
],
]);
}
}
```
### Eloquent Database Monitoring
OpenTelemetry automatically instruments Eloquent ORM to provide comprehensive
database query monitoring and performance insights.
#### Automatic Query Tracing
Once configured, all Eloquent queries are automatically traced with detailed information:
```php
// This query is automatically instrumented
$users = User::where('active', true)
->with('posts')
->limit(10)
->get();
// The trace will show:
// - SQL query statement
// - Database name and operation
// - Query duration
// - Bindings (obfuscated for security)
```
#### Query Builder and Raw SQL
All database interactions are automatically traced:
```php showLineNumbers
// Query Builder (automatically traced)
$articles = DB::table('articles')
->join('users', 'articles.user_id', '=', 'users.id')
->where('articles.published', true)
->orderBy('articles.created_at', 'desc')
->get();
// Raw SQL queries (automatically traced)
$results = DB::select('SELECT * FROM users WHERE active = ?', [true]);
// Transactions (automatically traced with span hierarchy)
DB::transaction(function () {
$order = Order::create([...]);
$order->items()->createMany([...]);
});
```
**Span attributes include:**
- `db.system` - Database type (mysql, pgsql, sqlite)
- `db.name` - Database name
- `db.statement` - SQL query (obfuscated)
- `db.operation` - Operation type (SELECT, INSERT, UPDATE, DELETE)
- `db.sql.table` - Table name
#### Detecting N+1 Queries
Use OpenTelemetry traces to identify and fix N+1 query problems:
```php
// Bad: N+1 query pattern (visible in traces as multiple DB spans)
$posts = Post::limit(10)->get();
foreach ($posts as $post) {
echo $post->author->name; // Triggers 10 additional queries
}
// Good: Optimized with eager loading (single query in trace)
$posts = Post::with('author')->limit(10)->get();
foreach ($posts as $post) {
echo $post->author->name; // No additional queries
}
```
In Scout Dashboard, N+1 queries will appear as:
- Multiple identical database spans within a single request trace
- High span count for simple operations
- Repeated query patterns with different parameters
### Custom Manual Instrumentation
While automatic instrumentation covers most Laravel components, you can add
custom instrumentation for business logic, external API calls, or
performance-critical code paths.
#### Creating Custom Spans
Create custom spans for important business operations:
```php showLineNumbers title="app/Http/Controllers/OrderController.php"
getTracer('orders-controller', '1.0.0');
return $tracer->spanBuilder('create_order')
->setSpanKind(SpanKind::KIND_SERVER)
->setAttribute('user.id', $request->user()->id)
->setAttribute('order.items_count', count($request->items))
->startSpan()
->activate(function ($span) use ($request) {
try {
$span->addEvent('Validating order data');
$order = Order::create([
'user_id' => $request->user()->id,
'items' => $request->items,
'total_amount' => $request->total_amount,
]);
$span->addEvent('Order saved successfully', [
'order.id' => $order->id,
'order.total' => $order->total_amount,
]);
// Process payment in nested span
$this->processPayment($order);
// Send confirmation email in nested span
$this->sendConfirmation($order);
$span->setStatus(StatusCode::STATUS_OK);
return response()->json($order, 201);
} catch (\Exception $e) {
$span->recordException($e);
$span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
return response()->json(['error' => $e->getMessage()], 422);
}
});
}
private function processPayment(Order $order): void
{
$tracer = Globals::tracerProvider()->getTracer('orders-controller', '1.0.0');
$tracer->spanBuilder('process_payment')
->setSpanKind(SpanKind::KIND_INTERNAL)
->startSpan()
->activate(function ($span) use ($order) {
// Payment processing logic
$span->setAttribute('payment.amount', $order->total_amount);
$span->setAttribute('payment.status', 'completed');
});
}
private function sendConfirmation(Order $order): void
{
$tracer = Globals::tracerProvider()->getTracer('orders-controller', '1.0.0');
$tracer->spanBuilder('send_confirmation_email')
->setSpanKind(SpanKind::KIND_INTERNAL)
->startSpan()
->activate(function ($span) use ($order) {
// Email sending logic
$span->addEvent('Email queued', [
'email.to' => $order->user->email,
]);
});
}
}
```
#### Adding Middleware for Request Context
Enrich all requests with user context:
```php showLineNumbers title="app/Http/Middleware/AddTraceContext.php"
isRecording()) {
// Add request attributes
$span->setAttribute('http.route', $request->route()?->getName());
$span->setAttribute('http.request_id', $request->header('X-Request-ID'));
// Add user context if authenticated
if ($request->user()) {
$span->setAttribute('user.id', $request->user()->id);
$span->setAttribute('user.email', $request->user()->email);
$span->setAttribute('user.authenticated', true);
}
}
return $next($request);
}
}
```
Register the middleware in `app/Http/Kernel.php`:
```php showLineNumbers title="app/Http/Kernel.php"
protected $middleware = [
// ... other middleware
\App\Http\Middleware\AddTraceContext::class,
];
```
#### Instrumenting External API Calls
Add custom instrumentation for external API calls:
```php showLineNumbers title="app/Services/ExternalApiClient.php"
getTracer('external-api-client', '1.0.0');
return $tracer->spanBuilder('external_api_call')
->setSpanKind(SpanKind::KIND_CLIENT)
->setAttribute('http.url', $endpoint)
->setAttribute('http.method', 'GET')
->startSpan()
->activate(function ($span) use ($endpoint) {
try {
$response = Http::get($endpoint);
$span->setAttribute('http.status_code', $response->status());
$span->setAttribute('http.response_size', strlen($response->body()));
if ($response->successful()) {
$span->setStatus(StatusCode::STATUS_OK);
return $response->json();
}
$span->setStatus(StatusCode::STATUS_ERROR, "HTTP {$response->status()}");
throw new \Exception("API request failed with status {$response->status()}");
} catch (\Exception $e) {
$span->recordException($e);
$span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
throw $e;
}
});
}
}
```
#### Using Semantic Conventions
Follow OpenTelemetry semantic conventions for consistent attribute naming:
```php showLineNumbers
// HTTP semantic conventions
$span->setAttribute('http.method', 'POST');
$span->setAttribute('http.url', 'https://api.example.com/users');
$span->setAttribute('http.status_code', 201);
$span->setAttribute('http.request.header.content_type', 'application/json');
// Database semantic conventions
$span->setAttribute('db.system', 'postgresql');
$span->setAttribute('db.name', 'production');
$span->setAttribute('db.statement', 'SELECT * FROM users WHERE active = ?');
$span->setAttribute('db.operation', 'SELECT');
// Messaging/Queue semantic conventions
$span->setAttribute('messaging.system', 'redis');
$span->setAttribute('messaging.destination', 'emails');
$span->setAttribute('messaging.operation', 'process');
```
### Running Your Instrumented Application
#### Development Mode
For local development, verify instrumentation is working:
```bash showLineNumbers
# Set environment variables
export OTEL_PHP_AUTOLOAD_ENABLED=true
export OTEL_SERVICE_NAME=laravel-app-dev
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_LOG_LEVEL=debug
# Start Laravel development server
php artisan serve
```
Visit `http://localhost:8000/api/health/telemetry` to verify configuration.
#### Production Mode
For production deployments, ensure the Scout Collector endpoint is properly configured:
```bash showLineNumbers
# Set production environment variables
export APP_ENV=production
export OTEL_SERVICE_NAME=laravel-app-production
export OTEL_SERVICE_VERSION=2.1.0
export OTEL_EXPORTER_OTLP_ENDPOINT=https://scout-collector.example.com/v1/traces
export SCOUT_CLIENT_ID=your_client_id
export SCOUT_CLIENT_SECRET=your_client_secret
# Optimize Laravel for production
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Start production server (use PHP-FPM + Nginx in production)
php artisan serve --host=0.0.0.0 --port=8000
```
#### Docker Deployment
Run your instrumented Laravel application in Docker:
```bash showLineNumbers
# Build the image
docker build -t laravel-app:latest .
# Run with Scout Collector
docker run -d \
--name laravel-app \
-e OTEL_PHP_AUTOLOAD_ENABLED=true \
-e OTEL_SERVICE_NAME=laravel-app \
-e OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4318 \
-e DB_CONNECTION=pgsql \
-e DB_HOST=postgres \
-p 8000:8000 \
laravel-app:latest
```
Or use Docker Compose (see [Production Configuration](#production-configuration)
section for complete example).
### Troubleshooting
#### Verifying OpenTelemetry Installation
Check if the OpenTelemetry extension is loaded:
```bash showLineNumbers
# Verify extension is loaded
php -m | grep opentelemetry
# Check extension version
php -r "echo phpversion('opentelemetry');"
# Verify configuration
php -i | grep -i otel
```
Expected output:
```plaintext
opentelemetry
1.0.0
OTEL_PHP_AUTOLOAD_ENABLED => true
OTEL_SERVICE_NAME => laravel-app
```
#### Testing Instrumentation in Tinker
Test your OpenTelemetry configuration using Laravel Tinker:
```php
php artisan tinker
// Check if extension is loaded
>>> extension_loaded('opentelemetry');
=> true
// Verify environment variables
>>> env('OTEL_SERVICE_NAME');
=> "laravel-app"
>>> env('OTEL_EXPORTER_OTLP_ENDPOINT');
=> "http://localhost:4318"
```
#### Debug Mode
Enable debug logging to troubleshoot instrumentation issues:
```bash showLineNumbers
export OTEL_LOG_LEVEL=debug
export OTEL_PHP_INTERNAL_METRICS_ENABLED=true
php artisan serve
```
Check Laravel logs for OpenTelemetry debug information:
```bash
tail -f storage/logs/laravel.log
```
#### Common Issues
##### Issue: No traces appearing in Scout Dashboard
**Solutions:**
1. Verify Scout Collector endpoint is reachable:
```bash
curl -v http://scout-collector:4318/v1/traces
```
2. Check environment variables are set:
```bash
php artisan tinker
>>> env('OTEL_EXPORTER_OTLP_ENDPOINT');
>>> env('OTEL_SERVICE_NAME');
```
3. Enable debug logging and check for export errors:
```bash
export OTEL_LOG_LEVEL=debug
php artisan serve
```
4. Verify network connectivity between Laravel app and Scout Collector
##### Issue: OpenTelemetry extension not loaded
**Solutions:**
1. Verify extension installation:
```bash
pecl list | grep opentelemetry
```
2. Check php.ini includes extension directive:
```bash
php --ini | head -1
cat /path/to/php.ini | grep opentelemetry
```
3. Ensure extension file exists:
```bash
find /usr -name "opentelemetry.so" 2>/dev/null
```
4. Restart PHP-FPM if using FastCGI:
```bash
sudo systemctl restart php8.2-fpm
```
##### Issue: Missing database query spans
**Solutions:**
1. Ensure auto-instrumentation is enabled:
```bash
echo $OTEL_PHP_AUTOLOAD_ENABLED # Should be "true"
```
2. Verify database connection is active:
```bash
php artisan tinker
>>> DB::connection()->getPdo();
```
3. Check that Laravel instrumentation package is installed:
```bash
composer show | grep opentelemetry-auto-laravel
```
##### Issue: High memory usage
**Solutions:**
1. Reduce batch queue size:
```bash
export OTEL_BSP_MAX_QUEUE_SIZE=1024
```
2. Increase export frequency:
```bash
export OTEL_BSP_SCHEDULE_DELAY_MILLIS=2000
```
3. Monitor PHP memory limit:
```bash
php -i | grep memory_limit
```
##### Issue: Performance degradation
**Solutions:**
1. Verify batch span processor is being used (not simple processor)
2. Skip health check endpoints by configuring routes:
```php
// Don't trace health check endpoints
Route::get('/health', function () {
return response()->json(['status' => 'ok']);
})->withoutMiddleware([\App\Http\Middleware\AddTraceContext::class]);
```
3. Use selective instrumentation if full auto-instrumentation is too heavy
### Security Considerations
#### Protecting Sensitive Data
Avoid adding sensitive information to span attributes:
```php
// Bad - exposes sensitive data
$span->setAttribute('user.password', $user->password); // Never!
$span->setAttribute('credit_card.number', $request->cc_number); // Never!
$span->setAttribute('user.ssn', $user->social_security_number); // Never!
// Good - uses safe identifiers
$span->setAttribute('user.id', $user->id);
$span->setAttribute('user.role', $user->role);
$span->setAttribute('payment.provider', 'stripe');
$span->setAttribute('payment.status', 'completed');
```
#### SQL Query Obfuscation
OpenTelemetry PHP automatically obfuscates SQL parameter values in database spans:
Before obfuscation (never sent):
```sql
SELECT * FROM users WHERE email = 'user@example.com' AND password = 'secret123'
```
After obfuscation (what gets sent):
```sql
SELECT * FROM users WHERE email = ? AND password = ?
```
#### Filtering Sensitive HTTP Headers
Configure which HTTP headers are captured in spans:
```bash showLineNumbers title=".env"
# Only capture safe headers
OTEL_HTTP_HEADERS_ALLOWED=content-type,accept,user-agent
OTEL_HTTP_HEADERS_BLOCKED=authorization,cookie,x-api-key
```
#### Compliance Considerations
For applications handling regulated data (GDPR, HIPAA, PCI-DSS):
- Never include personally identifiable information (PII) in spans
- Use hashed or anonymized user identifiers
- Implement data retention policies in Scout Dashboard
- SQL obfuscation is enabled by default for database queries
- Audit span attributes regularly for sensitive data leaks
- Configure allowed/blocked HTTP headers appropriately
### Performance Considerations
#### Expected Performance Impact
OpenTelemetry instrumentation adds minimal overhead to Laravel applications:
- **Average latency increase**: 2-4ms per request
- **CPU overhead**: Less than 3% in production with batch processing
- **Memory overhead**: ~80-120MB depending on queue size and traffic
**Impact varies based on:**
- Number of enabled instrumentations
- Application request volume
- Complexity of database queries
- Number of external API calls
#### Optimization Best Practices
##### 1. Use Batch Span Processing
```bash
# Production settings (low overhead)
OTEL_BSP_MAX_QUEUE_SIZE=2048
OTEL_BSP_SCHEDULE_DELAY_MILLIS=5000
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
```
##### 2. Enable OPcache
```ini showLineNumbers title="php.ini"
[opcache]
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; Disable in production
```
##### 3. Skip Non-Critical Endpoints
Configure routes that don't need tracing:
```php showLineNumbers title="routes/web.php"
// Health checks don't need full tracing
Route::get('/health', function () {
return response()->json(['status' => 'ok']);
})->withoutMiddleware();
Route::get('/metrics', function () {
return response()->json([/* metrics */]);
})->withoutMiddleware();
```
##### 4. Use Redis for Queue Monitoring
For high-throughput queue processing, ensure efficient monitoring:
```bash showLineNumbers title=".env"
QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis # Faster than predis
```
##### 5. Enable GZIP Compression
```bash
OTEL_EXPORTER_OTLP_COMPRESSION=gzip
```
Reduces network bandwidth by 70-80%.
### Frequently Asked Questions
#### Does OpenTelemetry impact Laravel performance?
OpenTelemetry adds approximately 2-4ms of latency per request in typical Laravel
applications. With proper configuration (batch processing, GZIP compression), the
performance impact is minimal and acceptable for most production workloads.
#### Which Laravel versions are supported?
OpenTelemetry supports Laravel 8.0+ with PHP 8.0+. Laravel 10.x or 11.x with
PHP 8.2+ is recommended for optimal compatibility and performance. See the
[Prerequisites](#prerequisites) section for detailed version compatibility.
#### Can I use OpenTelemetry with Laravel queues and job workers?
Yes! The `opentelemetry-auto-laravel` package includes automatic instrumentation
for Laravel queue workers. Background jobs are traced automatically, and you can
see the complete trace from HTTP request through asynchronous job processing in
Scout Dashboard.
#### Is OpenTelemetry compatible with Laravel middleware?
Yes, OpenTelemetry instruments at the HTTP request level, making it compatible with
all Laravel middleware. Custom middleware will appear in traces automatically.
#### Can I use OpenTelemetry alongside other APM tools?
Yes, OpenTelemetry can run alongside tools like New Relic or DataDog during
migration periods. However, running multiple APM agents simultaneously will
multiply the performance overhead, so plan your migration carefully.
#### How do I handle multi-tenant Laravel applications?
Add tenant context to spans using middleware:
```php
$span = Span::getCurrent();
$span->setAttribute('tenant.id', $request->tenant->id);
$span->setAttribute('tenant.name', $request->tenant->name);
```
Then filter traces by tenant in Scout Dashboard.
#### What's the difference between traces and metrics?
**Traces** show the complete request flow through your application with timing
details for each operation. Use traces to debug slow requests and understand
distributed transactions.
**Metrics** provide aggregated statistics over time (request rate, error rate,
latency percentiles). Use metrics for monitoring overall application health
and setting alerts.
#### How do I monitor Eloquent N+1 queries?
OpenTelemetry traces automatically expose N+1 queries as multiple database
spans within a single request trace. In Scout Dashboard, look for repeated
query patterns or high span counts for simple operations.
#### Can I use OpenTelemetry with Laravel Octane?
Yes! OpenTelemetry is compatible with Laravel Octane (Swoole/RoadRunner). Ensure
the extension is loaded in your Octane worker process by checking `php -m` in
the Octane container.
#### How do I instrument Laravel scheduled tasks (cron jobs)?
Scheduled tasks are automatically instrumented when using auto-instrumentation.
Each scheduled command execution creates a trace you can view in Scout Dashboard.
#### Does OpenTelemetry work with Laravel Livewire?
Yes, Laravel Livewire HTTP requests are automatically traced. Component lifecycle
events (mount, render, etc.) appear as spans in your traces.
#### Can I customize which database queries are traced?
Yes, you can create custom middleware or database event listeners to selectively
skip tracing certain queries. However, we recommend tracing all queries for
complete observability.
### What's Next?
Now that your Laravel application is instrumented with OpenTelemetry, explore
these resources to maximize your observability:
#### Advanced Topics
- **[Custom PHP Instrumentation](../custom-instrumentation/php.md)** - Deep dive
into manual tracing, custom spans, and advanced instrumentation patterns
- **[PostgreSQL Monitoring Best Practices](../../component/postgres.md)** - Optimize
database observability with connection pooling metrics and query performance analysis
- **[Redis Instrumentation](../../component/redis.md)** - Monitor caching
performance and identify slow Redis operations
#### Scout Platform Features
- **[Creating Alerts](../../../guides/creating-alerts-with-logx.md)** - Set up
intelligent alerts for error rates, latency thresholds, and custom metrics
- **[Dashboard Creation](../../../guides/create-your-first-dashboard.md)** - Build
custom dashboards combining traces, metrics, and business KPIs
#### Deployment and Operations
- **[Docker Compose Setup](../../collector-setup/docker-compose-example.md)** -
Set up Scout Collector for local development and testing
### Complete Example
Here's a complete working example of a Laravel application with OpenTelemetry instrumentation.
#### Example Project 1: Laravel 13 + PHP 8.5 + PostgreSQL
Based on our [GitHub example repository](https://github.com/base-14/examples/tree/main/php/php85-laravel13-postgres).
##### composer.json
```json title="composer.json"
{
"name": "laravel/laravel",
"type": "project",
"require": {
"php": "^8.2",
"laravel/framework": "^11.0",
"open-telemetry/sdk": "^1.6",
"open-telemetry/exporter-otlp": "^1.3",
"open-telemetry/opentelemetry-auto-laravel": "^1.2"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
}
}
```
##### Environment Configuration
```bash title=".env"
APP_NAME=laravel-otel-example
APP_ENV=production
APP_DEBUG=false
# Database
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=laravel
DB_USERNAME=laravel
DB_PASSWORD=secret
# OpenTelemetry Configuration
OTEL_PHP_AUTOLOAD_ENABLED=true
OTEL_SERVICE_NAME=laravel-app
OTEL_SERVICE_VERSION=1.0.0
OTEL_TRACES_EXPORTER=otlp
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4318
OTEL_PROPAGATORS=baggage,tracecontext
# Scout Collector (for production)
SCOUT_ENDPOINT=https://your-tenant.base14.io/v1/traces
SCOUT_CLIENT_ID=your_client_id
SCOUT_CLIENT_SECRET=your_client_secret
SCOUT_TOKEN_URL=https://your-tenant.base14.io/oauth/token
```
##### Dockerfile
```dockerfile title="Dockerfile"
FROM php:8.2-fpm-alpine
# Install dependencies
RUN apk add --no-cache \
autoconf \
build-base \
postgresql-dev \
libzip-dev
# Install PHP extensions
RUN docker-php-ext-install pdo pdo_pgsql zip opcache
# Install OpenTelemetry extension
RUN pecl install opentelemetry && \
docker-php-ext-enable opentelemetry
WORKDIR /var/www/html
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader
COPY . .
ENV OTEL_PHP_AUTOLOAD_ENABLED=true
ENV OTEL_SERVICE_NAME=laravel-app
EXPOSE 8000
CMD ["php", "artisan", "serve", "--host=0.0.0.0"]
```
#### Example Project 2: Laravel 8 + SQLite (Legacy)
For legacy applications still using Laravel 8 (see [GitHub example](https://github.com/base-14/examples/tree/main/php/php8-laravel8-sqlite)):
> ⚠️ **Security Warning**: Laravel 8 reached end-of-life in July 2023. This example
> is provided for reference only and should not be used in production.
```bash title=".env"
APP_NAME=laravel-8-legacy
DB_CONNECTION=sqlite
OTEL_PHP_AUTOLOAD_ENABLED=true
OTEL_SERVICE_NAME=laravel-8-legacy
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
```
Complete working examples are available in our [GitHub examples repository](https://github.com/base-14/examples/tree/main/php).
Once telemetry is flowing, you can
[monitor Laravel request performance in Scout](https://base14.io/scout/apm)
— track Eloquent queries, queue throughput, and HTTP latency from a
unified dashboard.
### References
- [Official OpenTelemetry PHP Documentation](https://opentelemetry.io/docs/languages/php/)
- [OpenTelemetry Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/)
- [Laravel Documentation](https://laravel.com/docs)
### Related Guides
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) - Set
up collector for local development.
- [PHP Custom Instrumentation](../custom-instrumentation/php.md) - Manual spans
and advanced patterns.
- [WordPress](./wordpress.md) - self-hosted WordPress on Apache or PHP-FPM.
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language.
---
## Litestar OpenTelemetry Instrumentation - Async API Tracing
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
Implement OpenTelemetry instrumentation for Litestar applications to capture
distributed traces, metrics, and structured logs from your async Python APIs.
This guide shows you how to auto-instrument Litestar with the
`opentelemetry-instrument` CLI wrapper and the `OpenTelemetryPlugin` shipped in
`litestar.contrib.opentelemetry`, so HTTP server spans, asyncpg queries, httpx
outbound calls, and SQLAlchemy operations land in your collector with no SDK
boilerplate in your application code.
Litestar is a modern ASGI Python framework, a close alternative to
[FastAPI](./fast-api.md) with a more batteries-included feel than
[Flask](./flask.md).
Litestar uses its own custom ASGI router, which means the generic
`opentelemetry-instrumentation-asgi` package cannot produce HTTP server spans
for it on its own. The Litestar team ships a first-class plugin precisely for
this case, and combining it with the standard auto-instrumentation distro
gives you a complete picture: server spans from Litestar, query spans from
asyncpg, client spans from httpx, ORM spans from SQLAlchemy, and trace-IDs
injected onto every JSON log line. All of it driven by environment variables,
all of it production-ready.
Whether you are migrating from DataDog or New Relic, standing up observability
for the first time on a greenfield Litestar service, or replacing a DIY
logging setup with OTLP-exported telemetry, this guide walks through a
working two-service example: a Litestar articles API backed by PostgreSQL
(asyncpg + SQLAlchemy + Alembic) that calls a sibling Litestar notify service
over httpx. You will see how a single `POST /api/articles` flows through both
services under one trace ID, why `BEGIN`/`COMMIT` spans are noise worth
filtering, and how to add a `articles.created` counter without writing any SDK
plumbing yourself.
:::tip TL;DR
Add `OpenTelemetryPlugin(config=OpenTelemetryConfig())` to your Litestar
`plugins` list, install `opentelemetry-distro` plus the asyncpg, httpx, and
logging contrib packages, and boot uvicorn under `opentelemetry-instrument`.
Set `OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=asgi` so the generic ASGI patch
does not double-handle requests, and `OTEL_PYTHON_LOG_CORRELATION=true` so
trace IDs land on your log records. Traces, metrics, and logs export to
base14 Scout via OTLP with no changes to your route handlers.
:::
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Who This Guide Is For
This documentation is designed for:
- **Litestar developers**: building production async APIs with msgspec
validation, advanced-alchemy repositories, and uvicorn deployments.
- **DevOps engineers**: containerising Litestar services with Docker Compose
or Kubernetes and wiring them into an OTel collector.
- **Backend teams**: migrating from FastAPI to Litestar and looking for
parity in their observability stack.
- **Engineering teams**: switching off DataDog, New Relic, or AppDynamics in
favour of open-source OpenTelemetry exported to base14 Scout.
- **Platform engineers**: standardising tracing, metrics, and structured
logging across multiple Python microservices that share a collector.
### Overview
This guide demonstrates how to:
- Instrument a Litestar app using `OpenTelemetryPlugin` from the official
`litestar.contrib.opentelemetry` module.
- Auto-instrument asyncpg, SQLAlchemy, and httpx via the
`opentelemetry-instrument` CLI wrapper.
- Inject trace IDs onto every Python `LogRecord` via
`OTEL_PYTHON_LOG_CORRELATION=true` and surface them in JSON logs.
- Add custom counters and span attributes without writing SDK setup code.
- Filter out liveness probes and asyncpg transaction-lifecycle noise at the
collector.
- Run the full stack locally with Docker Compose against a real Postgres.
- Export OTLP/HTTP to base14 Scout with OAuth2 client credentials and gzip.
#### Prerequisites
Before starting, ensure you have:
- **Python 3.11 or later** installed (Python 3.14 recommended for best
performance and free-threaded build support).
- **Litestar 2.0 or later** installed in your project (2.21.1 used in the
example).
- **PostgreSQL 14+** if you intend to use the asyncpg + SQLAlchemy combination
(Postgres 18 in the example).
- **Scout Collector** configured and accessible from your application.
- See [Docker Compose Setup](../../collector-setup/docker-compose-example.md)
for local development.
- See [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md)
for production deployment.
- **Basic understanding** of OpenTelemetry concepts (traces, spans, metrics,
resources, propagators).
- Access to package installation via `pip`, `poetry`, or `uv`.
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version | Notes |
| ---------------------------------------- | --------------- | ------------------- | ---------------------------------------------------------------- |
| **Python** | 3.11 | 3.14 | Litestar 2.x supports 3.8+; 3.11+ recommended for asyncio perf. |
| **Litestar** | 2.0 | 2.21.1+ | Earlier 1.x is the legacy "Starlite" name, schema differs. |
| **OpenTelemetry SDK** | 1.30.0 | 1.41+ | Core SDK for traces, metrics, logs. |
| **OpenTelemetry contrib instrumentations** | 0.50b0 | 0.62b0+ | asyncpg, httpx, SQLAlchemy, logging packages. |
| **opentelemetry-distro** | 0.50b0 | 0.62b0+ | Provides the `opentelemetry-instrument` CLI wrapper. |
| **asyncpg** (optional) | 0.27 | 0.31.0 | Native Postgres driver. |
| **SQLAlchemy** (optional) | 2.0 | 2.0.49 | 2.x async API required for the example. |
| **httpx** (optional) | 0.24 | 0.28.1 | For outbound HTTP tracing and W3C propagation. |
| **uvicorn** | 0.20 | 0.30+ | ASGI server; the `opentelemetry-instrument` wrapper boots it. |
#### What Gets Instrumented
OpenTelemetry produces the following telemetry for the Litestar example
shipped at [python/litestar-postgres](https://github.com/base-14/examples/tree/main/python/litestar-postgres):
| Source | Telemetry produced | Driver |
| ---------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------- |
| HTTP server | One server span per request (method, route, status, duration) | `litestar.contrib.opentelemetry.OpenTelemetryPlugin` |
| Database (Postgres) | One client span per asyncpg statement (prepared statement text, duration) | `opentelemetry-instrumentation-asyncpg` |
| ORM | One client span per SQLAlchemy operation (logical SQL, dialect) | `opentelemetry-instrumentation-sqlalchemy` |
| Outbound HTTP | One client span per httpx call, with `traceparent` header injection | `opentelemetry-instrumentation-httpx` |
| Logs | `otelTraceID`, `otelSpanID`, `otelTraceSampled`, `otelServiceName` on every record | `opentelemetry-instrumentation-logging` |
| Custom metrics | `articles.created` counter; `notifications.received` counter | OpenTelemetry Meter API in `src/telemetry.py` |
The complete working example with two services, Alembic migrations, a
collector config, an end-to-end smoke script, and a verifier that proves
telemetry reached Scout is at
[python/litestar-postgres](https://github.com/base-14/examples/tree/main/python/litestar-postgres).
Read along with this guide.
### Installation
The Litestar example uses `uv` as its package manager but the OpenTelemetry
packages install identically with `pip` or Poetry. Pick the tab that matches
your project.
```mdx-code-block
```
```bash
uv add opentelemetry-api opentelemetry-sdk \
opentelemetry-exporter-otlp opentelemetry-distro \
opentelemetry-instrumentation \
opentelemetry-instrumentation-asgi \
opentelemetry-instrumentation-asyncpg \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-httpx \
opentelemetry-instrumentation-logging
```
```mdx-code-block
```
```bash
pip install opentelemetry-api opentelemetry-sdk \
opentelemetry-exporter-otlp opentelemetry-distro \
opentelemetry-instrumentation \
opentelemetry-instrumentation-asgi \
opentelemetry-instrumentation-asyncpg \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-httpx \
opentelemetry-instrumentation-logging
```
```mdx-code-block
```
```bash
poetry add opentelemetry-api opentelemetry-sdk \
opentelemetry-exporter-otlp opentelemetry-distro \
opentelemetry-instrumentation \
opentelemetry-instrumentation-asgi \
opentelemetry-instrumentation-asyncpg \
opentelemetry-instrumentation-sqlalchemy \
opentelemetry-instrumentation-httpx \
opentelemetry-instrumentation-logging
```
```mdx-code-block
```
#### Pinned Dependencies
For reproducible production builds, pin the OpenTelemetry packages alongside
your Litestar project. The example uses these versions in
`app/pyproject.toml`:
```toml title="app/pyproject.toml" showLineNumbers
[project]
name = "litestar-articles"
version = "0.1.0"
description = "Litestar + PostgreSQL articles API with OpenTelemetry instrumentation"
requires-python = ">=3.14"
dependencies = [
"litestar[standard]==2.21.1",
"msgspec==0.21.1",
"sqlalchemy[asyncio]==2.0.49",
"asyncpg==0.31.0",
"alembic==1.18.4",
"advanced-alchemy==1.9.3",
"httpx==0.28.1",
"python-json-logger==4.1.0",
# OpenTelemetry — SDK + auto-instrumentation
"opentelemetry-api==1.41.0",
"opentelemetry-sdk==1.41.0",
"opentelemetry-exporter-otlp==1.41.0",
"opentelemetry-distro==0.62b0",
"opentelemetry-instrumentation==0.62b0",
"opentelemetry-instrumentation-asgi==0.62b0",
"opentelemetry-instrumentation-sqlalchemy==0.62b0",
"opentelemetry-instrumentation-httpx==0.62b0",
"opentelemetry-instrumentation-asyncpg==0.62b0",
"opentelemetry-instrumentation-logging==0.62b0",
]
```
#### Why a Distro Plus Per-Library Packages
`opentelemetry-distro` provides the `opentelemetry-instrument` CLI wrapper
that reads `OTEL_*` environment variables, builds a `TracerProvider` and
`MeterProvider`, and patches every installed instrumentation package before
your application code runs. The per-library packages
(`opentelemetry-instrumentation-asyncpg`, etc.) are what get picked up by the
distro at boot. Install them both - the distro alone does not include the
contrib instrumentations.
### Configuration
Litestar instrumentation needs two pieces wired up: the
`OpenTelemetryPlugin` inside your application code, and `OTEL_*` environment
variables consumed by the wrapper at boot. Both are required, and the order
matters - the wrapper sets the global `TracerProvider` before uvicorn imports
your code, so the plugin picks up the same provider automatically.
#### The OpenTelemetryPlugin
Litestar uses a custom router that does not match the generic ASGI app shape,
so `opentelemetry-instrumentation-asgi` cannot produce HTTP server spans for
it on its own. The Litestar team ships
`litestar.contrib.opentelemetry.OpenTelemetryPlugin` precisely for this:
```python title="app/src/main.py" showLineNumbers
from advanced_alchemy.extensions.litestar import (
AsyncSessionConfig,
SQLAlchemyAsyncConfig,
SQLAlchemyPlugin,
)
from litestar import Litestar
from litestar.contrib.opentelemetry import OpenTelemetryConfig, OpenTelemetryPlugin
from litestar.di import Provide
from src.config import Settings
from src.controllers.article import ArticleController
from src.controllers.health import HealthController
from src.logging_config import build_logging_config
from src.services.notification import NotificationService
def create_app(notification_service: NotificationService | None = None) -> Litestar:
settings = Settings.from_env()
notifier = notification_service or NotificationService(url=settings.notify_url)
db_config = SQLAlchemyAsyncConfig(
connection_string=settings.database_url,
session_config=AsyncSessionConfig(expire_on_commit=False),
create_all=False,
)
# Litestar uses a custom ASGI router, so the generic
# opentelemetry-instrumentation-asgi auto-patch does not produce
# server spans for it. This plugin wires the same instrumentation
# into Litestar's request lifecycle properly.
otel_config = OpenTelemetryConfig()
return Litestar(
route_handlers=[HealthController, ArticleController],
plugins=[
SQLAlchemyPlugin(config=db_config),
OpenTelemetryPlugin(config=otel_config),
],
dependencies={
"notification_service": Provide(lambda: notifier, sync_to_thread=False)
},
on_shutdown=[notifier.aclose],
logging_config=build_logging_config(),
)
app = create_app()
```
Pair this with `OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=asgi` in your
environment to make the choice explicit and avoid the generic ASGI auto-patch
double-handling requests.
#### Configuration Approaches
Pick the approach that matches your deployment target. The example uses
Docker Compose, but pure environment variables and shell-driven local dev are
equally supported.
```mdx-code-block
```
Set these on your shell, your systemd unit, or your Kubernetes Pod spec.
Boot uvicorn through the `opentelemetry-instrument` wrapper to install the
SDK before your application code runs.
```bash
export OTEL_SERVICE_NAME=litestar-postgres-app
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development,environment=development,service.version=1.0.0
export OTEL_PYTHON_LOG_CORRELATION=true
export OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=asgi
export OTEL_METRIC_EXPORT_INTERVAL=60000
export OTEL_BSP_SCHEDULE_DELAY=5000
opentelemetry-instrument uvicorn src.main:app --host 0.0.0.0 --port 8080
```
```mdx-code-block
```
The example bakes `opentelemetry-instrument` directly into the container
`CMD`. Note the `alembic upgrade head` runs first so the schema is current
before traffic arrives, and the wrapper instruments the uvicorn process that
follows.
```dockerfile title="app/Dockerfile" showLineNumbers
FROM python:3.14-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.6.12 /uv /usr/local/bin/uv
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock* ./
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
UV_PROJECT_ENVIRONMENT=/app/.venv
RUN uv sync --no-dev --frozen
FROM python:3.14-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/* \
&& adduser --disabled-password --gecos '' --uid 1000 appuser
COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv
COPY --chown=appuser:appuser . .
ENV PATH=/app/.venv/bin:$PATH \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \
CMD curl -f http://localhost:8080/api/health || exit 1
# Run migrations then boot uvicorn under the OTel auto-instrumentation wrapper
# (instruments ASGI, SQLAlchemy, httpx, asyncpg, logging).
CMD ["sh", "-c", "alembic upgrade head && opentelemetry-instrument uvicorn src.main:app --host 0.0.0.0 --port 8080"]
```
```mdx-code-block
```
Setting all `OTEL_*` variables in `compose.yml` keeps the Dockerfile generic
and the configuration discoverable. The example uses this pattern for both
services.
```yaml title="compose.yml" showLineNumbers
services:
app:
build:
context: ./app
dockerfile: Dockerfile
container_name: litestar-app
env_file:
- path: .env
required: false
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgresql+asyncpg://${DB_USERNAME:-postgres}:${DB_PASSWORD:?DB_PASSWORD is required}@postgres:5432/${DB_NAME:-articles}
- NOTIFY_URL=http://notify:8081/notify
- OTEL_SERVICE_NAME=litestar-postgres-app
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
- OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-deployment.environment=development,environment=development,service.version=1.0.0}
- OTEL_PYTHON_LOG_CORRELATION=true
- OTEL_METRIC_EXPORT_INTERVAL=10000
- OTEL_BSP_SCHEDULE_DELAY=2000
# Litestar uses its own ASGI router, so we instrument it via
# OpenTelemetryPlugin in code. Disable the generic ASGI auto-patch
# to make the choice explicit (and avoid double-handling).
- OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=asgi
depends_on:
postgres:
condition: service_healthy
otel-collector:
condition: service_started
notify:
condition: service_healthy
networks:
- app-network
```
```mdx-code-block
```
#### Key Environment Variables
| Variable | Purpose | Example value |
| ------------------------------------- | -------------------------------------------------------- | --------------------------------------------------- |
| `OTEL_SERVICE_NAME` | One unique value per service. | `litestar-postgres-app` |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector address. | `http://otel-collector:4318` |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | `http/protobuf` or `grpc`. | `http/protobuf` |
| `OTEL_RESOURCE_ATTRIBUTES` | Comma-separated `k=v` resource tags. | `deployment.environment=development,service.version=1.0.0` |
| `OTEL_PYTHON_LOG_CORRELATION` | Inject trace IDs onto Python LogRecords. | `true` |
| `OTEL_PYTHON_DISABLED_INSTRUMENTATIONS` | Skip auto-patching for the named entry-points. | `asgi` |
| `OTEL_METRIC_EXPORT_INTERVAL` | Milliseconds between metric flushes. | `60000` (prod) / `10000` (dev) |
| `OTEL_BSP_SCHEDULE_DELAY` | Milliseconds between span batch flushes. | `5000` (prod) / `2000` (dev) |
### Production Configuration
The defaults that come with `opentelemetry-instrument` are tuned for
correctness, not for cost. A handful of knobs make the difference between an
expensive pipeline and a frugal one.
#### BatchSpanProcessor Tuning
The Python SDK's BatchSpanProcessor flushes spans either when the batch fills
up or when `OTEL_BSP_SCHEDULE_DELAY` elapses, whichever comes first.
```bash
# Faster export - useful for local dev where you want to see spans within seconds.
export OTEL_BSP_SCHEDULE_DELAY=2000
export OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
# Frugal export - cuts egress bandwidth and reduces collector wakeups in production.
export OTEL_BSP_SCHEDULE_DELAY=5000
export OTEL_BSP_MAX_EXPORT_BATCH_SIZE=2048
export OTEL_BSP_MAX_QUEUE_SIZE=4096
```
`OTEL_METRIC_EXPORT_INTERVAL` does the same job for metrics. The example uses
10 s in dev so the verifier script finishes inside a minute; production
defaults of 60 s are appropriate for most workloads.
#### Compression and Resource Attributes
OTLP/HTTP supports gzip compression. Enable it on the collector exporter (the
example sets `compression: gzip`); the SDK side respects
`OTEL_EXPORTER_OTLP_COMPRESSION` if you export directly without a collector.
Resource attributes attach to every span, metric, and log emitted by the
process. Set `service.version`, `deployment.environment`, and any other
constants once via `OTEL_RESOURCE_ATTRIBUTES`:
```bash
export OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development,environment=development,service.version=2.7.1,service.instance.id=$(hostname)
```
#### Collector Config
The collector is where you do the bulk of the cleanup. Filter probes, drop
transaction-lifecycle noise, and route to base14 Scout via OAuth2 client
credentials:
```yaml title="config/otel-config.yaml" showLineNumbers
extensions:
oauth2client:
client_id: ${SCOUT_CLIENT_ID}
client_secret: ${SCOUT_CLIENT_SECRET}
token_url: ${SCOUT_TOKEN_URL}
endpoint_params:
audience: b14collector
timeout: 10s
tls:
insecure_skip_verify: true
health_check:
endpoint: 0.0.0.0:13133
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
limit_mib: 256
check_interval: 1s
batch:
timeout: 10s
send_batch_size: 1024
filter/noisy:
error_mode: ignore
traces:
span:
# Drop liveness probes from both services so they do not pollute traces.
# litestar-postgres-app uses /api/health, litestar-postgres-notify uses /health.
- 'IsMatch(name, ".*(/api)?/health.*")'
# Drop asyncpg transaction-lifecycle spans — BEGIN/COMMIT/ROLLBACK add
# noise without telling you anything the INSERT/SELECT spans don't.
- 'IsMatch(name, "^(BEGIN|COMMIT|ROLLBACK)( TRANSACTION)?;?$")'
resource:
attributes:
- key: deployment.environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
- key: environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
exporters:
otlp_http/b14:
endpoint: ${SCOUT_ENDPOINT}
auth:
authenticator: oauth2client
tls:
insecure_skip_verify: true
compression: gzip
timeout: 30s
retry_on_failure:
enabled: true
initial_interval: 1s
max_interval: 30s
max_elapsed_time: 300s
debug:
verbosity: detailed
service:
extensions: [oauth2client, health_check]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, filter/noisy, resource, batch]
exporters: [otlp_http/b14, debug]
metrics:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [otlp_http/b14, debug]
logs:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [otlp_http/b14, debug]
```
`tls.insecure_skip_verify: true` is for local development against a Scout
endpoint without a fully trusted certificate chain - never ship it to
production. Remove it once your endpoint is publicly trusted.
#### Multi-Service Distributed Tracing
The example ships two Litestar services that share a single trace ID per
inbound request. The article service receives `POST /api/articles`, writes to
Postgres, then calls the notify service over httpx. Because
`opentelemetry-instrumentation-httpx` is enabled, the `traceparent` header is
injected automatically and the notify service's `OpenTelemetryPlugin` extracts
it on the way in.
```text
litestar-postgres-app
├── HTTP server span (POST /api/articles)
│ ├── asyncpg span (INSERT INTO articles ...)
│ ├── asyncpg span (SELECT ... FROM articles WHERE id = $1)
│ └── httpx client span (POST http://notify:8081/notify)
│ └── litestar-postgres-notify
│ └── HTTP server span (POST /notify)
```
All four spans share one trace ID. Click any span in base14 Scout and you
jump to the structured logs emitted by both services for that request.
### Framework-Specific Features
#### Async Database Spans (asyncpg + SQLAlchemy)
The example layers SQLAlchemy 2.x async over asyncpg via `advanced-alchemy`.
Both packages are auto-instrumented when their respective contrib packages
are installed; you get logical SQL from SQLAlchemy and the underlying prepared
statements from asyncpg, both attached to the active server span.
```python title="app/src/repository.py" showLineNumbers
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import Article
class ArticleRepository(SQLAlchemyAsyncRepository[Article]):
model_type = Article
async def provide_article_repo(db_session: AsyncSession) -> ArticleRepository:
return ArticleRepository(session=db_session)
```
A single `repo.add(article, auto_commit=True)` call produces a SQLAlchemy
client span describing the insert and an asyncpg client span describing the
prepared statement that hit Postgres - both as children of the inbound HTTP
server span. There is nothing to wire up in the repository itself.
#### Outbound HTTP and Distributed Context
`opentelemetry-instrumentation-httpx` patches `httpx.AsyncClient` so every
outbound call becomes a client span and gets a `traceparent` header. The
example wraps a single shared client per service (pool churn would otherwise
dominate the latency budget):
```python title="app/src/services/notification.py" showLineNumbers
import httpx
class NotificationService:
"""Pooled httpx wrapper. Subclassed in tests to record/fail without network."""
def __init__(self, url: str, timeout: float = 5.0) -> None:
self.url = url
self._client: httpx.AsyncClient | None = httpx.AsyncClient(timeout=timeout)
async def send(self, *, article_id: int, title: str) -> None:
assert self._client is not None, "NotificationService used after close"
response = await self._client.post(
self.url,
json={"article_id": article_id, "title": title},
)
response.raise_for_status()
async def aclose(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
```
The `aclose` method is wired into Litestar's `on_shutdown` hook so the
connection pool drains cleanly during graceful termination.
#### Trace-Correlated Structured Logs
`opentelemetry-instrumentation-logging` injects four attributes onto every
Python `LogRecord` when `OTEL_PYTHON_LOG_CORRELATION=true`:
```text
otelTraceID otelSpanID otelTraceSampled otelServiceName
```
Surface those keys in your formatter and you get trace-log correlation for
free in base14 Scout. The example uses `python-json-logger` and Litestar's
`LoggingConfig`:
```python title="app/src/logging_config.py" showLineNumbers
from litestar.logging.config import LoggingConfig
_FORMAT = (
"%(asctime)s %(levelname)s %(name)s %(message)s "
"%(otelTraceID)s %(otelSpanID)s %(otelTraceSampled)s %(otelServiceName)s"
)
def build_logging_config() -> LoggingConfig:
return LoggingConfig(
formatters={
"json": {
"()": "pythonjsonlogger.json.JsonFormatter",
"format": _FORMAT,
}
},
handlers={
"default": {
"class": "logging.StreamHandler",
"formatter": "json",
"stream": "ext://sys.stdout",
}
},
loggers={
"uvicorn.access": {
"level": "INFO",
"handlers": ["default"],
"propagate": False,
},
"uvicorn.error": {
"level": "INFO",
"handlers": ["default"],
"propagate": False,
},
"sqlalchemy.engine": {
"level": "WARNING",
"handlers": ["default"],
"propagate": False,
},
},
root={"level": "INFO", "handlers": ["default"]},
)
```
Returning a `LoggingConfig` object (rather than mutating the `logging` module
directly) keeps Litestar from clobbering your handlers during init.
#### msgspec Request and Response Bodies
Litestar uses msgspec by default for request validation and response
serialisation. msgspec itself does not need instrumentation - the HTTP server
span emitted by `OpenTelemetryPlugin` already covers the full request
lifecycle including msgspec decode/encode. If you want to break out validation
time as a separate span, wrap the call manually (see Custom Instrumentation).
### Custom Instrumentation
Auto-instrumentation covers HTTP, database, and outbound HTTP. Anything
business-specific you add yourself, with the same OpenTelemetry API the
contrib packages use under the hood.
#### Adding Span Attributes from a Handler
The active span inside any Litestar handler is the HTTP server span. Use the
OpenTelemetry tracer API to tag it with anything you want to search by in
Scout - request IDs, tenant IDs, the new row's primary key:
```python title="app/src/controllers/article.py" showLineNumbers
from opentelemetry import trace
from src.telemetry import articles_created
class ArticleController(Controller):
path = "/api/articles"
dependencies = {"repo": Provide(provide_article_repo)}
@post("/")
async def create(
self,
data: ArticleCreate,
repo: ArticleRepository,
notification_service: NotificationService,
) -> ArticleRead:
article = await repo.add(
Article(title=data.title, body=data.body), auto_commit=True
)
# Tag the active server span with the new ID so trace search by
# `article.id` works in Scout — this is the canonical pattern for
# adding business attributes to auto-instrumented spans.
trace.get_current_span().set_attribute("article.id", article.id)
articles_created.add(1)
logger.info("article created", extra={"article_id": article.id})
try:
await notification_service.send(article_id=article.id, title=article.title)
except Exception as exc:
logger.warning(
"notification dispatch failed",
extra={"article_id": article.id, "error": str(exc)},
)
return ArticleRead.from_model(article)
```
#### Custom Counters
Acquire a Meter at module load time and create instruments alongside it. The
`MeterProvider` is set up by `opentelemetry-instrument` before uvicorn imports
your code, so the Counter binds to the real OTLP-exporting provider, never
the no-op default:
```python title="app/src/telemetry.py" showLineNumbers
from opentelemetry import metrics
_meter = metrics.get_meter("litestar-postgres-app")
articles_created = _meter.create_counter(
name="articles.created",
description="Number of articles successfully created",
unit="1",
)
```
Bumping the counter is one line:
```python
articles_created.add(1)
```
The same pattern works for histograms (`create_histogram`), up-down counters
(`create_up_down_counter`), and observable gauges
(`create_observable_gauge`). All of them export through the same OTLP
pipeline as auto-instrumented metrics.
#### Manual Spans for Business Logic
For long-running internal operations that you want broken out from the parent
HTTP server span, create a child span with `tracer.start_as_current_span`:
```python showLineNumbers
from opentelemetry import trace
tracer = trace.get_tracer("litestar-postgres-app")
async def regenerate_search_index(repo: ArticleRepository) -> int:
with tracer.start_as_current_span("articles.reindex") as span:
items, total = await repo.list_and_count()
span.set_attribute("articles.count", total)
# ... call your search-index client here ...
return total
```
The child span inherits the trace ID from the surrounding HTTP server span,
so the reindex shows up as a nested span in the trace waterfall.
#### Trace ID in Response Headers
Returning the trace ID to the caller makes incident response dramatically
easier - the user can paste the ID into Scout to find the exact request:
```python showLineNumbers
from litestar import Request, Response, get
from opentelemetry import trace
@get("/api/articles/{article_id:int}")
async def get_one_with_trace(
request: Request,
article_id: int,
repo: ArticleRepository,
) -> Response[ArticleRead]:
article = await repo.get_one_or_none(id=article_id)
if article is None:
raise NotFoundException(detail=f"Article {article_id} not found")
trace_id = format(trace.get_current_span().get_span_context().trace_id, "032x")
return Response(
content=ArticleRead.from_model(article),
headers={"X-Trace-Id": trace_id},
)
```
### Running Your Application
The example ships a self-contained four-service stack: two Litestar services,
Postgres, and an OTel collector. The Makefile wraps the common operations.
#### Local Development with Docker Compose
```bash
git clone https://github.com/base-14/examples.git
cd examples/python/litestar-postgres
cp .env.example .env
# edit .env to set DB_PASSWORD; SCOUT_* vars are optional
make docker-up # build + start all 4 services
./scripts/test-api.sh # CRUD smoke
make docker-down
```
#### Verify Telemetry End-to-End
Tail the collector to see telemetry land as it is exported:
```bash
docker compose logs -f otel-collector
```
After a single `POST /api/articles` you should see:
1. **One trace ID** appearing in spans from both `litestar-postgres-app`
(HTTP server, asyncpg INSERT/SELECT, httpx client) and
`litestar-postgres-notify` (HTTP server). The notify service's parent span
ID is the httpx client span ID - that is distributed tracing working.
`BEGIN`/`COMMIT`/`ROLLBACK` transaction-lifecycle spans are dropped by the
collector's `filter/noisy` processor - they add volume without insight.
2. **`articles.created`** as a cumulative monotonic Sum metric, with a value
matching how many articles you have POSTed since startup.
3. **JSON log lines** in `app` and `notify` stdout containing `otelTraceID`,
`otelSpanID`, `otelServiceName` - the same trace ID you saw in the spans.
This is what powers the "jump from span to logs" UI flow in Scout.
#### Smoke Test the API
`scripts/test-api.sh` exercises the full CRUD surface and exits non-zero on
the first failure, so it slots cleanly into CI:
```bash title="scripts/test-api.sh" showLineNumbers
#!/usr/bin/env bash
# End-to-end CRUD smoke. Assumes `make docker-up` is already running.
# Exits non-zero on the first failure so it slots into CI cleanly.
set -euo pipefail
BASE_URL="${API_BASE_URL:-http://localhost:8080}"
NOTIFY_URL="${NOTIFY_BASE_URL:-http://localhost:8081}"
# 1. health checks
check "GET /api/health (articles)" 200 "$(status_of "$BASE_URL/api/health")"
check "GET /health (notify)" 200 "$(status_of "$NOTIFY_URL/health")"
# 2. create
RESP=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/api/articles" \
-H 'Content-Type: application/json' \
-d '{"title":"smoke","body":"created by test-api.sh"}')
# 3. get one
check "GET /api/articles/{id}" 200 "$(status_of "$BASE_URL/api/articles/$ARTICLE_ID")"
# 4. list with pagination
LIST_BODY=$(curl -s "$BASE_URL/api/articles?limit=10&offset=0")
# 5. update
check "PUT /api/articles/{id}" 200 "$(status_of -X PUT "$BASE_URL/api/articles/$ARTICLE_ID" \
-H 'Content-Type: application/json' \
-d '{"title":"smoke-updated","body":"after PUT"}')"
# 6. delete
check "DELETE /api/articles/{id}" 204 "$(status_of -X DELETE "$BASE_URL/api/articles/$ARTICLE_ID")"
```
#### Running Without Docker
The Litestar app itself runs anywhere uvicorn does. From the `app/` directory:
```bash
uv sync
export DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/articles
export OTEL_SERVICE_NAME=litestar-postgres-app
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_PYTHON_LOG_CORRELATION=true
export OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=asgi
alembic upgrade head
uv run opentelemetry-instrument uvicorn src.main:app --host 0.0.0.0 --port 8080
```
### Troubleshooting
#### No HTTP Server Spans Appear
**Symptom**: asyncpg and httpx client spans show up, but you never see a
parent HTTP server span - every client span is a root span.
**Cause**: `OpenTelemetryPlugin` is not in the `plugins` list, so Litestar's
custom router is not wired into the OTel context. The generic ASGI
auto-patch alone cannot trace Litestar.
**Fix**: Add the plugin to your Litestar instance:
```python
from litestar.contrib.opentelemetry import OpenTelemetryConfig, OpenTelemetryPlugin
app = Litestar(
route_handlers=[...],
plugins=[OpenTelemetryPlugin(config=OpenTelemetryConfig())],
)
```
#### Duplicate or Conflicting HTTP Spans
**Symptom**: every request produces two HTTP server spans - one named after
your route, one named `HTTP {method}`.
**Cause**: both `OpenTelemetryPlugin` and the generic ASGI auto-patch are
producing server spans. The generic patch picks up the inner ASGI app and
double-handles each request.
**Fix**: Disable the generic ASGI auto-patch by setting
`OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=asgi` in your environment.
#### Trace IDs Missing from Log Lines
**Symptom**: Your JSON logs ship to Scout, but `otelTraceID` is always
`"0"` or absent.
**Cause**: One of three things - `OTEL_PYTHON_LOG_CORRELATION` is unset,
`opentelemetry-instrumentation-logging` is not installed, or the formatter
does not surface the injected fields.
**Fix**:
1. Confirm the env var is set: `echo $OTEL_PYTHON_LOG_CORRELATION` should
print `true`.
2. Confirm the package is installed:
`pip show opentelemetry-instrumentation-logging`.
3. Confirm your formatter format string includes the keys:
```python
_FORMAT = (
"%(asctime)s %(levelname)s %(name)s %(message)s "
"%(otelTraceID)s %(otelSpanID)s %(otelTraceSampled)s %(otelServiceName)s"
)
```
#### Health Check Probes Polluting Traces
**Symptom**: Your trace volume is dominated by `/health` and `/api/health`
spans from Kubernetes liveness probes.
**Cause**: probes hit your service every few seconds and produce a span every
time.
**Fix**: drop them at the collector with a filter processor. The example
config does this:
```yaml
processors:
filter/noisy:
error_mode: ignore
traces:
span:
- 'IsMatch(name, ".*(/api)?/health.*")'
```
Filtering at the collector is preferable to filtering at the SDK because it
keeps your application code simple and works for every probe path
consistently across services.
#### asyncpg Spans Show `BEGIN` / `COMMIT` Noise
**Symptom**: Every CRUD request produces three spans called `BEGIN`,
`INSERT`, `COMMIT`. Your trace waterfall is double the height it needs to
be.
**Cause**: asyncpg emits a span for every prepared statement, including
transaction-lifecycle ones. They double trace volume without revealing
anything the INSERT/SELECT spans don't already.
**Fix**: drop them at the collector with a filter rule:
```yaml
processors:
filter/noisy:
traces:
span:
- 'IsMatch(name, "^(BEGIN|COMMIT|ROLLBACK)( TRANSACTION)?;?$")'
```
If you need them for diagnosing a transaction-isolation bug, comment the
rule out temporarily - the SDK is still emitting them; they just stop
landing downstream.
### Security Considerations
#### Sensitive Data in Span Attributes
Auto-instrumentation captures HTTP request/response headers, query
parameters, and prepared statement text. Before exporting to a third-party
backend, audit what is being captured:
- **HTTP headers**: `Authorization`, `Cookie`, and custom auth headers may
appear in `http.request.header.*` attributes. Litestar's
`OpenTelemetryConfig` lets you filter them via the `server_request_hook`
parameter - set sensitive header names to `[REDACTED]` before the span
closes.
- **Query parameters**: API tokens passed as `?api_key=...` show up in the
`url.full` attribute. Strip them at the SDK or collector before export.
- **SQL parameter values**: SQLAlchemy auto-instrumentation can be
configured to omit bound parameters (`enable_commenter=False` and the
`tracer_provider` hooks). asyncpg captures prepared-statement text but
not the bound parameter values, which is normally what you want.
#### PII in Custom Span Attributes
When you call `set_attribute` yourself, you control what gets recorded. Avoid
attaching email addresses, full names, IP addresses, or anything that
identifies a user directly. Use opaque IDs (`tenant.id`, `user.id`) and look
up the PII separately in your application database when needed.
#### Compliance: GDPR, HIPAA, SOC 2
OpenTelemetry is a transport - what makes a deployment compliant or not is
what attributes you capture, where you store them, and who can read them.
For regulated workloads:
- Run the collector in the same trust zone as your application, with
outbound traffic restricted to your observability backend's endpoint.
- Use the collector's `attributes` and `redaction` processors to strip PII
before export. Don't rely on the application to get this right consistently.
- Configure data retention on the backend side. base14 Scout supports
per-tenant retention policies; configure them to match your compliance
posture.
- Encrypt OTLP in transit. The example collector exporter uses TLS
(`tls.insecure_skip_verify` is for local development only - remove it in
production).
#### Authentication to the Collector
The example uses OAuth2 client credentials between the collector and base14
Scout, with the secret materialised from environment variables, never
committed to the repository:
```yaml
extensions:
oauth2client:
client_id: ${SCOUT_CLIENT_ID}
client_secret: ${SCOUT_CLIENT_SECRET}
token_url: ${SCOUT_TOKEN_URL}
```
Rotate `SCOUT_CLIENT_SECRET` regularly and inject it via your secret manager
of choice (AWS Secrets Manager, HashiCorp Vault, Kubernetes Secrets) - never
bake it into a container image.
### Performance Considerations
#### Measured Overhead
For the example articles API on Python 3.14 / Litestar 2.21.1, with all six
contrib instrumentations enabled, the measured overhead per `POST
/api/articles` is:
| Metric | Without OTel | With OTel | Delta |
| ------------------------------- | ------------ | --------- | --------- |
| p50 latency | 12 ms | 14 ms | +2 ms |
| p99 latency | 28 ms | 32 ms | +4 ms |
| CPU per request | baseline | +3% | +3% |
| Resident memory (steady state) | 95 MB | 145 MB | +50 MB |
The bulk of the latency hit is asyncpg span construction; the SDK itself
contributes well under 1 ms per request. CPU and memory grow with span batch
size - the BatchSpanProcessor holds spans until the next flush.
#### Batch and Buffer Tuning
The defaults are fine for most workloads. Knobs that matter when you start
seeing dropped spans (visible as warnings in the SDK logs):
```bash
# Larger queue smooths out traffic bursts.
export OTEL_BSP_MAX_QUEUE_SIZE=4096
# Larger batches reduce export-call overhead.
export OTEL_BSP_MAX_EXPORT_BATCH_SIZE=2048
# Slower flush reduces collector wakeups, increases tail latency to dashboard.
export OTEL_BSP_SCHEDULE_DELAY=5000
```
The metric-side knobs are simpler: `OTEL_METRIC_EXPORT_INTERVAL` controls
how often counters and histograms ship. 60 s is the OTel default; the
example uses 10 s in dev so the verifier finishes quickly.
#### Health Check Filtering
Liveness probes from Docker, Kubernetes, or your load balancer hit your
service every few seconds. Without filtering, they dominate span volume.
Always filter `/health`-style paths at the collector. The example config
shows the pattern - a regex against `name` in the `filter/noisy` processor.
#### Connection Pooling
The httpx auto-instrumentation does not change pool behaviour, but how you
construct the client does. Always reuse a single `httpx.AsyncClient` across
requests; constructing a new client per call destroys the keep-alive pool
and adds TLS handshake latency to every outbound call. The example wraps a
shared client in a service class and closes it on Litestar shutdown.
#### What Auto-Instrumentation Does Not Do
- It does not instrument framework-internal hooks (Litestar guards,
middlewares written by you, custom dependencies). Wrap them manually with
`tracer.start_as_current_span` if you need to.
- It does not group asyncpg `BEGIN`/`COMMIT` spans with the surrounding
request - they appear as separate child spans of the server span. Drop
them at the collector if they are noise to you.
- It does not propagate context across `asyncio.create_task` if the task is
spawned from a synchronous frame. Use `with trace.use_span(...)` inside
the task body to re-attach context.
### FAQ
#### Why does Litestar need OpenTelemetryPlugin instead of just opentelemetry-instrument?
Litestar uses its own ASGI router rather than the generic ASGI app pattern,
so `opentelemetry-instrumentation-asgi` cannot produce HTTP server spans
for it automatically. The `OpenTelemetryPlugin` from
`litestar.contrib.opentelemetry` hooks into Litestar's request lifecycle
directly. Set `OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=asgi` to avoid double
handling.
#### What is the performance overhead of OpenTelemetry on Litestar?
Typical overhead is 1-3 ms added latency per HTTP request, 2-5% CPU
increase, and 30-60 MB additional memory. The BatchSpanProcessor exports
telemetry asynchronously, so request latency is not blocked on collector
network calls. Filter health checks at the collector to keep span volume
proportional to real traffic.
#### Which Python and Litestar versions are supported?
Python 3.11+ minimum (3.14 recommended), Litestar 2.0+ (2.21.1+
recommended), OpenTelemetry SDK 1.30+ (1.41+ recommended), and contrib
instrumentations 0.50b0+ (0.62b0+ recommended). The example pins Python
3.14 and Litestar 2.21.1.
#### How does asyncpg auto-instrumentation differ from SQLAlchemy instrumentation?
`opentelemetry-instrumentation-asyncpg` patches the asyncpg driver,
capturing every prepared statement and query as a span.
`opentelemetry-instrumentation-sqlalchemy` hooks the engine, capturing
logical SQL with bound parameters. Both can be enabled together when
SQLAlchemy is layered over asyncpg, which is the typical Litestar setup -
you get the high-level operation from SQLAlchemy and the wire-level
statement from asyncpg, both attached to the same parent span.
#### How do I correlate logs with traces in Litestar?
Set `OTEL_PYTHON_LOG_CORRELATION=true`. The
`opentelemetry-instrumentation-logging` package injects `otelTraceID`,
`otelSpanID`, `otelTraceSampled`, and `otelServiceName` onto every Python
`LogRecord`. Include those keys in your JSON formatter and Litestar's
`LoggingConfig` surfaces them on every log line. Trace-to-log navigation
in base14 Scout uses `otelTraceID` as the join key.
#### Can I add custom span attributes from Litestar route handlers?
Yes. Call `trace.get_current_span().set_attribute(key, value)` inside your
handler. The active span is the HTTP server span produced by
`OpenTelemetryPlugin`, so any attribute you set is searchable in base14
Scout per request. The example tags `article.id` on every successful
create.
#### Why are BEGIN and COMMIT spans missing from my traces?
In the example collector config they are dropped by a `filter/noisy`
processor because asyncpg emits a span per transaction-lifecycle statement.
`BEGIN`, `COMMIT`, and `ROLLBACK` add volume without insight beyond what
the INSERT/SELECT spans already show. Remove the filter rule to keep them
if you are debugging a transaction-isolation issue.
#### How does distributed tracing work between two Litestar services?
`opentelemetry-instrumentation-httpx` injects W3C `traceparent` headers on
outbound HTTP requests. The receiving Litestar service's
`OpenTelemetryPlugin` extracts the headers and creates a child span under
the same trace ID. No code changes required on either side - it works as
long as both services have the plugin and the contrib package installed.
#### Does OpenTelemetry support Litestar WebSockets and Server-Sent Events?
`OpenTelemetryPlugin` produces a single long-lived span for WebSocket
connections, covering the connection lifetime. Server-Sent Events produce
a normal HTTP server span. For per-message tracing inside a WebSocket,
create child spans manually with the OpenTelemetry tracer API.
#### How do I add custom metrics like a counter to a Litestar handler?
Call `metrics.get_meter(name)` to acquire a Meter, then `create_counter()`
at module load time and call `.add(1)` inside the handler. The
`MeterProvider` is initialised by `opentelemetry-instrument` before
uvicorn imports your code, so the counter binds to the real OTLP-exporting
provider. The example does this in `app/src/telemetry.py` for
`articles.created`.
#### How do I disable a specific auto-instrumentation?
Use `OTEL_PYTHON_DISABLED_INSTRUMENTATIONS` with a comma-separated list of
entry-point names. The example disables `asgi` so the generic ASGI patch
does not double-handle Litestar requests. The full list of names is
visible in `pip show opentelemetry-instrumentation-asgi` and similar
packages - each contrib package registers itself under a short name.
#### Can I use this guide with the legacy Starlite name?
Starlite was renamed to Litestar at version 2.0. The
`litestar.contrib.opentelemetry` plugin is only on the Litestar branch -
on Starlite 1.x you have to use `opentelemetry-instrumentation-asgi` and
accept that the spans will be coarser. Migrating to Litestar 2.x is the
right answer.
### What's Next
You now have working OpenTelemetry instrumentation for a Litestar service
backed by asyncpg, SQLAlchemy, and httpx, with structured logs correlated
to traces and a custom counter exporting alongside the auto-instrumented
metrics. From here:
- **Wire metric dashboards**: build Scout dashboards over `articles.created`
and your auto-instrumented HTTP histograms to track p50/p99 per route.
- **Add error tracking**: hook `Exception` events on the active span via
`span.record_exception(exc)` from your handler error paths.
- **Wrap slow internal operations**: any background task or third-party
client call benefits from a manual child span - the surrounding HTTP span
already gives you the context.
[base14 Scout](https://base14.io) provides managed OTLP ingestion, dashboards,
and alerting for OpenTelemetry data, so the same guide that gets you spans
locally also gets you a production observability backend with no extra SDK
configuration.
### Complete Example
The full working example with two Litestar services, Postgres, the OTel
collector, Alembic migrations, and end-to-end verification scripts is at
[python/litestar-postgres](https://github.com/base-14/examples/tree/main/python/litestar-postgres).
Layout:
```text
litestar-postgres/
├── app/ # litestar-postgres-app service
│ ├── src/
│ │ ├── main.py # create_app() factory + module-level `app`
│ │ ├── config.py # env-driven Settings
│ │ ├── models.py # Article ORM + Base
│ │ ├── repository.py # SQLAlchemyAsyncRepository[Article]
│ │ ├── telemetry.py # OTel Meter + articles.created counter
│ │ ├── logging_config.py # JSON formatter wired via Litestar LoggingConfig
│ │ ├── controllers/ # health.py, article.py
│ │ └── services/ # notification.py (httpx client)
│ ├── alembic/ # async migrations
│ ├── tests/ # pytest (12 tests)
│ ├── pyproject.toml # uv project
│ └── Dockerfile
├── notify/ # litestar-postgres-notify service
│ ├── src/{main.py,logging_config.py,telemetry.py}
│ ├── tests/ # pytest (2 tests)
│ ├── pyproject.toml
│ └── Dockerfile
├── config/otel-config.yaml # collector pipeline (debug + Scout)
├── compose.yml # 4 services
├── Makefile # sync/test/lint/format/audit/check + docker-* targets
└── scripts/
├── test-api.sh # CRUD smoke against running stack
└── verify-scout.sh # end-to-end OTel pipeline verification
```
To run it:
```bash
git clone https://github.com/base-14/examples.git
cd examples/python/litestar-postgres
cp .env.example .env
# edit .env to set DB_PASSWORD; SCOUT_* vars are optional for local-only dev
make docker-up
./scripts/test-api.sh
make docker-down
```
The `notify` service's `main.py` shows the minimum-viable Litestar OTel setup
- same plugin, same wrapper, no database:
```python title="notify/src/main.py" showLineNumbers
import logging
import msgspec
from litestar import Controller, Litestar, get, post
from litestar.contrib.opentelemetry import OpenTelemetryConfig, OpenTelemetryPlugin
from src.logging_config import build_logging_config
from src.telemetry import notifications_received
logger = logging.getLogger(__name__)
class NotifyPayload(msgspec.Struct):
article_id: int
title: str
class HealthController(Controller):
path = "/health"
@get("/")
async def health(self) -> dict[str, str]:
return {"status": "ok", "service": "litestar-postgres-notify"}
class NotifyController(Controller):
path = "/notify"
@post("/", status_code=200)
async def notify(self, data: NotifyPayload) -> dict[str, object]:
notifications_received.add(1)
logger.info(
"article notification received",
extra={"article_id": data.article_id, "title": data.title},
)
return {"received": True, "article_id": data.article_id}
app = Litestar(
route_handlers=[HealthController, NotifyController],
plugins=[OpenTelemetryPlugin(config=OpenTelemetryConfig())],
logging_config=build_logging_config(),
)
```
### References
- [Litestar OpenTelemetry plugin documentation](https://docs.litestar.dev/2/usage/plugins/opentelemetry.html)
- [OpenTelemetry Python documentation](https://opentelemetry.io/docs/languages/python/)
- [opentelemetry-distro on PyPI](https://pypi.org/project/opentelemetry-distro/)
- [opentelemetry-instrumentation-asyncpg on PyPI](https://pypi.org/project/opentelemetry-instrumentation-asyncpg/)
- [opentelemetry-instrumentation-sqlalchemy on PyPI](https://pypi.org/project/opentelemetry-instrumentation-sqlalchemy/)
- [opentelemetry-instrumentation-httpx on PyPI](https://pypi.org/project/opentelemetry-instrumentation-httpx/)
- [W3C Trace Context specification](https://www.w3.org/TR/trace-context/)
- [base14 Scout](https://base14.io)
### Related Guides
- [Django Instrumentation](./django.md) - Batteries-included Python framework
- [Python Custom Instrumentation](../custom-instrumentation/python.md) - Manual
spans and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
- [Docker Compose Collector Setup](../../collector-setup/docker-compose-example.md)
- local OTel collector for development.
---
## LlamaIndex OpenTelemetry Instrumentation - RAG & Agent Monitoring Guide
## LlamaIndex
Implement OpenTelemetry instrumentation for LlamaIndex applications to enable
comprehensive AI application monitoring, LLM cost tracking, and quality
evaluation. This guide shows you how to instrument a LlamaIndex-powered content
quality agent with custom GenAI semantic convention spans, multi-provider LLM
support, structured output with self-correction, token and cost metrics, PII
scrubbing, and eval-driven development with Promptfoo.
LlamaIndex is a Python framework for LLM data and RAG applications, alongside
[LangGraph](./langgraph.md) for agents and the TypeScript
[Vercel AI SDK](./vercel-ai-sdk.md).
This guide intentionally uses **custom OpenTelemetry GenAI semantic
conventions** rather than OpenInference or LlamaIndex auto-instrumentation.
OpenInference produces non-standard attributes (`llm.*`, `input.*`, `output.*`)
that pollute telemetry with framework-specific data outside the OTel GenAI
semconv. Custom instrumentation gives you full control over what gets recorded
and ensures your telemetry works with any OpenTelemetry-compatible backend.
Whether you're building AI agents, content analysis pipelines, RAG systems, or
multi-provider LLM applications, this guide provides production-ready patterns
for unified AI observability where LLM spans, token metrics, cost attribution,
and evaluation scores live alongside your standard HTTP and database telemetry
in a single trace.
> **Note:** For general LLM observability patterns applicable to any Python
> framework, see the
> [LLM Observability guide](../../../guides/ai-observability/llm-observability.md).
> This guide focuses specifically on LlamaIndex integration patterns.
:::tip TL;DR
Use custom OpenTelemetry spans with GenAI semantic conventions to instrument
LlamaIndex - not OpenInference or the built-in auto-instrumentation. Wrap each
LLM call in a span that records model, token counts, and cost using a pricing
table, then attach a PII scrubber before recording prompts and completions. Pair
this with versioned YAML prompt templates and Promptfoo evaluations for a full
production observability setup.
:::
### Who This Guide Is For
This documentation is designed for:
- **AI/ML engineers**: building LlamaIndex-powered features and needing
visibility into model performance, cost, and quality
- **Backend developers**: adding AI capabilities to existing FastAPI
applications and wanting unified tracing
- **Platform teams**: standardizing observability across AI services and
traditional microservices
- **Engineering teams**: migrating from LangSmith or other proprietary AI
observability tools to OpenTelemetry
- **DevOps engineers**: deploying AI applications with production monitoring,
cost alerting, and quality tracking
### Overview
This guide demonstrates how to:
- Set up unified OpenTelemetry for a LlamaIndex application (traces + metrics +
logs)
- Create custom LLM spans following OpenTelemetry GenAI semantic conventions
- Support multiple LLM providers (OpenAI, Anthropic, Google) through a single
interface
- Implement structured output with JSON self-correction loops
- Track token usage and calculate cost per LLM call with a pricing table
- Record evaluation metrics for content quality tracking
- Scrub PII from prompts and completions before recording in telemetry
- Manage prompts with versioned YAML templates
- Run eval-driven development with Promptfoo
- Deploy with Docker Compose and the OpenTelemetry Collector
### Prerequisites
Before starting, ensure you have:
- **Python 3.12 or later** installed (3.14+ recommended)
- **An LLM API key** from at least one provider (OpenAI, Anthropic, or Google)
- **Scout Collector** configured and accessible
- See [Docker Compose Setup](../../collector-setup/docker-compose-example.md)
for local development
- Basic understanding of OpenTelemetry concepts (traces, spans, metrics)
#### Compatibility Matrix
| Component | Minimum Version | Recommended |
| ----------------------------- | --------------- | ----------- |
| Python | 3.12 | 3.14+ |
| opentelemetry-sdk | 1.39.0 | 1.39+ |
| opentelemetry-api | 1.39.0 | 1.39+ |
| FastAPI | 0.115+ | 0.128+ |
| llama-index-core | 0.14.0 | 0.14.13+ |
| llama-index-llms-openai | 0.6.0 | 0.6.18+ |
| llama-index-llms-anthropic | 0.10.0 | 0.10.8+ |
| llama-index-llms-google-genai | 0.8.0 | 0.8.7+ |
| Pydantic | 2.0 | 2.12.5+ |
### Installation
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
```bash showLineNumbers title="Terminal"
pip install \
opentelemetry-api \
opentelemetry-sdk \
opentelemetry-exporter-otlp-proto-http \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-logging \
llama-index-core \
llama-index-llms-openai \
llama-index-llms-anthropic \
llama-index-llms-google-genai \
fastapi uvicorn pydantic-settings \
tenacity httpx pyyaml
```
```mdx-code-block
```
```bash showLineNumbers title="Terminal"
uv add \
opentelemetry-api \
opentelemetry-sdk \
opentelemetry-exporter-otlp-proto-http \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-logging \
llama-index-core \
llama-index-llms-openai \
llama-index-llms-anthropic \
llama-index-llms-google-genai \
fastapi uvicorn pydantic-settings \
tenacity httpx pyyaml
```
```mdx-code-block
```
### Configuration
```mdx-code-block
```
```python showLineNumbers title="src/content_quality/telemetry.py"
import atexit
import logging
import os
from importlib.metadata import version
from typing import Any
from opentelemetry import _logs, metrics, trace
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
def setup_telemetry(
service_name: str,
otlp_endpoint: str,
) -> tuple[trace.Tracer, metrics.Meter]:
"""Initialize unified observability for traces, metrics, and logs.
GenAI telemetry (spans, metrics, events) is handled by custom
instrumentation in llm.py following OTel GenAI semantic conventions.
We intentionally do NOT use OpenInference/LlamaIndex auto-instrumentation.
"""
if os.environ.get("OTEL_SDK_DISABLED") == "true":
return trace.get_tracer(service_name), metrics.get_meter(service_name)
resource = Resource.create({
"service.name": service_name,
"service.version": version("ai-content-quality"),
"deployment.environment": os.getenv("SCOUT_ENVIRONMENT", "development"),
"environment": os.getenv("SCOUT_ENVIRONMENT", "development"),
})
trace_provider = TracerProvider(resource=resource)
trace_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint=f"{otlp_endpoint}/v1/traces")
)
)
trace.set_tracer_provider(trace_provider)
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=f"{otlp_endpoint}/v1/metrics"),
export_interval_millis=10000,
)
metric_provider = MeterProvider(
resource=resource, metric_readers=[metric_reader]
)
metrics.set_meter_provider(metric_provider)
log_provider = LoggerProvider(resource=resource)
log_provider.add_log_record_processor(
BatchLogRecordProcessor(
OTLPLogExporter(endpoint=f"{otlp_endpoint}/v1/logs")
)
)
_logs.set_logger_provider(log_provider)
logging.getLogger().addHandler(
LoggingHandler(level=logging.INFO, logger_provider=log_provider)
)
atexit.register(trace_provider.shutdown)
atexit.register(metric_provider.shutdown)
atexit.register(log_provider.shutdown)
LoggingInstrumentor().instrument(set_logging_format=True)
return trace.get_tracer(service_name), metrics.get_meter(service_name)
def instrument_fastapi(app: Any) -> None:
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
FastAPIInstrumentor.instrument_app(
app, excluded_urls="health", exclude_spans=["receive", "send"]
)
```
```mdx-code-block
```
```python showLineNumbers title="src/content_quality/config.py"
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
service_name: str = "ai-content-quality"
llm_provider: str = "openai"
llm_model: str = "gpt-4.1-nano"
llm_temperature: float = 0.3
llm_timeout: float = 30.0
openai_api_key: str = ""
google_api_key: str = ""
anthropic_api_key: str = ""
request_timeout: float = 60.0
review_prompt_version: str = "v1"
improve_prompt_version: str = "v1"
score_prompt_version: str = "v1"
otlp_endpoint: str = "http://otel-collector:4318"
otel_sdk_disabled: bool = False
scout_environment: str = "development"
host: str = "0.0.0.0"
port: int = 8000
@lru_cache
def get_settings() -> Settings:
return Settings()
```
```mdx-code-block
```
For container deployments where configuration is managed externally:
```bash showLineNumbers title=".env"
# Application
SERVICE_NAME=ai-content-quality
LLM_PROVIDER=openai
LLM_MODEL=gpt-4.1-nano
LLM_TEMPERATURE=0.3
LLM_TIMEOUT=30.0
REQUEST_TIMEOUT=60.0
HOST=0.0.0.0
PORT=8000
# LLM Provider Keys
OPENAI_API_KEY=sk-...
GOOGLE_API_KEY=
ANTHROPIC_API_KEY=
# Prompt Versions
REVIEW_PROMPT_VERSION=v1
IMPROVE_PROMPT_VERSION=v1
SCORE_PROMPT_VERSION=v1
# OpenTelemetry
OTLP_ENDPOINT=http://otel-collector:4318
OTEL_SDK_DISABLED=false
SCOUT_ENVIRONMENT=production
```
The Pydantic `Settings` class reads all environment variables automatically (see
the Pydantic Settings tab). No code changes needed - set the variables and the
application picks them up.
```mdx-code-block
```
### Production Configuration
#### OpenTelemetry Collector
```yaml showLineNumbers title="otel-collector-config.yaml"
extensions:
health_check:
endpoint: 0.0.0.0:13133
oauth2client:
client_id: ${SCOUT_CLIENT_ID}
client_secret: ${SCOUT_CLIENT_SECRET}
token_url: ${SCOUT_TOKEN_URL}
endpoint_params:
audience: b14collector
timeout: 10s
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
batch:
timeout: 10s
send_batch_size: 1024
attributes:
actions:
- key: deployment.environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
- key: environment
value: ${SCOUT_ENVIRONMENT}
action: upsert
exporters:
otlp_http/b14:
endpoint: ${SCOUT_ENDPOINT}
auth:
authenticator: oauth2client
compression: gzip
timeout: 30s
retry_on_failure:
enabled: true
debug:
verbosity: basic
service:
extensions: [health_check, oauth2client]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlp_http/b14, debug]
metrics:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlp_http/b14, debug]
logs:
receivers: [otlp]
processors: [memory_limiter, attributes, batch]
exporters: [otlp_http/b14, debug]
```
#### Docker Compose
```yaml showLineNumbers title="compose.yml"
services:
app:
build: .
ports:
- "8000:8000"
environment:
- OTLP_ENDPOINT=http://otel-collector:4318
- OTEL_SDK_DISABLED=false
- LLM_PROVIDER=${LLM_PROVIDER:-openai}
- LLM_MODEL=${LLM_MODEL:-gpt-4.1-nano}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- SCOUT_ENVIRONMENT=${SCOUT_ENVIRONMENT:-development}
depends_on:
otel-collector:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 60s
timeout: 5s
retries: 3
otel-collector:
image: otel/opentelemetry-collector-contrib:0.144.0
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
ports:
- "4317:4317"
- "4318:4318"
- "13133:13133"
environment:
- SCOUT_CLIENT_ID=${SCOUT_CLIENT_ID:-}
- SCOUT_CLIENT_SECRET=${SCOUT_CLIENT_SECRET:-}
- SCOUT_TOKEN_URL=${SCOUT_TOKEN_URL:-https://auth.base14.io/oauth/token}
- SCOUT_ENDPOINT=${SCOUT_ENDPOINT:-https://collector.base14.io}
- SCOUT_ENVIRONMENT=${SCOUT_ENVIRONMENT:-development}
```
#### Dockerfile
```dockerfile showLineNumbers title="Dockerfile"
FROM python:3.14-slim
WORKDIR /app
RUN pip install --no-cache-dir uv && \
apt-get update && apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --no-dev
COPY src/ src/
COPY prompts/ prompts/
ENV PYTHONPATH=/app/src
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "content_quality.main:app", \
"--host", "0.0.0.0", "--port", "8000"]
```
### Multi-Provider LLM Support
Create a provider-agnostic LLM factory that works with OpenAI, Anthropic, and
Google:
```python showLineNumbers title="src/content_quality/services/llm.py"
from llama_index.core.llms import LLM
PROVIDER_SEMCONV_NAMES = {
"openai": "openai",
"google": "gcp.gemini",
"anthropic": "anthropic",
}
PROVIDER_SERVERS = {
"openai": "api.openai.com",
"gcp.gemini": "generativelanguage.googleapis.com",
"anthropic": "api.anthropic.com",
}
def create_llm(
provider: str = "openai",
model: str = "gpt-4.1-nano",
temperature: float = 0.3,
api_key: str = "",
timeout: float = 30.0,
) -> LLM:
global _provider
_provider = PROVIDER_SEMCONV_NAMES.get(provider, provider)
if provider == "openai":
from llama_index.llms.openai import OpenAI
return OpenAI(model=model, temperature=temperature,
api_key=api_key, timeout=timeout)
if provider == "google":
from llama_index.llms.google_genai import GoogleGenAI
return GoogleGenAI(model=model, temperature=temperature,
api_key=api_key)
if provider == "anthropic":
from llama_index.llms.anthropic import Anthropic
return Anthropic(model=model, temperature=temperature,
api_key=api_key, timeout=timeout)
raise ValueError(f"Unknown LLM provider: {provider!r}")
```
### Structured Output with Self-Correction
The `generate_structured` function is the core instrumented LLM call. It
requests JSON output matching a Pydantic schema and retries with self-correction
if validation fails:
````python showLineNumbers title="src/content_quality/services/llm.py"
import json
import re
import time
from llama_index.core import PromptTemplate
from llama_index.core.llms import ChatMessage
from opentelemetry import metrics, trace
from opentelemetry.trace import StatusCode
from pydantic import BaseModel, ValidationError
from tenacity import (
retry, retry_if_exception_type,
stop_after_attempt, wait_exponential,
)
import httpx
from content_quality.pii import scrub_pii
MAX_PARSE_RETRIES = 2
_MARKDOWN_JSON_RE = re.compile(
r"^```(?:json)?\s*\n?(.*?)\n?\s*```$", re.DOTALL
)
def _strip_markdown_json(text: str) -> str:
text = text.strip()
m = _MARKDOWN_JSON_RE.match(text)
return m.group(1).strip() if m else text
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception_type(
(httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError)
),
before_sleep=_on_retry,
reraise=True,
)
async def generate_structured(
llm: LLM,
prompt_template: PromptTemplate,
output_cls: type[BaseModel],
content: str,
content_type: str = "general",
endpoint: str = "",
system_prompt: str = "",
) -> BaseModel:
tracer = trace.get_tracer("gen_ai.client")
model_name = llm.metadata.model_name
server_address = PROVIDER_SERVERS.get(_provider, "")
with tracer.start_as_current_span(f"chat {model_name}") as span:
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.request.model", model_name)
span.set_attribute("gen_ai.provider.name", _provider)
if server_address:
span.set_attribute("server.address", server_address)
span.set_attribute("gen_ai.output.type", "json")
span.set_attribute("content.type", content_type)
span.set_attribute("content.length", len(content))
start = time.perf_counter()
try:
formatted_prompt = prompt_template.format(content=content)
schema_json = json.dumps(
output_cls.model_json_schema(), indent=2
)
json_instruction = (
f"Respond ONLY with valid JSON matching this "
f"schema:\n{schema_json}"
)
full_system = (
f"{system_prompt}\n\n{json_instruction}"
if system_prompt else json_instruction
)
messages = [
ChatMessage(role="system", content=full_system),
ChatMessage(role="user", content=formatted_prompt),
]
chat_response = await llm.achat(messages)
duration = time.perf_counter() - start
# Record response attributes
_set_response_attrs(chat_response, span, model_name)
# Record token and cost metrics
_record_token_metrics(
chat_response, model_name, content_type, endpoint, span
)
# Optional: record prompt/completion events with PII scrubbing
if _is_content_capture_enabled():
_record_span_event(
span, system_prompt, formatted_prompt,
str(chat_response.message.content),
)
# Parse with self-correction loop
raw = _strip_markdown_json(str(chat_response.message.content))
for attempt in range(MAX_PARSE_RETRIES + 1):
try:
return output_cls.model_validate_json(raw)
except ValidationError as ve:
if attempt < MAX_PARSE_RETRIES:
messages.append(
ChatMessage(role="assistant", content=raw)
)
messages.append(ChatMessage(
role="user",
content=(
f"Your response did not match the schema. "
f"Error: {ve}\n"
"Please try again with valid JSON."
),
))
correction = await llm.achat(messages)
raw = _strip_markdown_json(
str(correction.message.content)
)
continue
raise
except Exception as e:
span.record_exception(e)
span.set_status(StatusCode.ERROR, str(e))
span.set_attribute("error.type", type(e).__name__)
error_counter.add(1, {
"gen_ai.request.model": model_name,
"gen_ai.provider.name": _provider,
"error.type": type(e).__name__,
})
raise
````
### Custom GenAI Metrics
Define five metrics following OpenTelemetry GenAI semantic conventions:
```python showLineNumbers title="src/content_quality/services/llm.py"
from opentelemetry import metrics
meter = metrics.get_meter("gen_ai.client")
token_usage = meter.create_histogram(
name="gen_ai.client.token.usage",
description="Number of tokens used",
unit="{token}",
)
operation_duration = meter.create_histogram(
name="gen_ai.client.operation.duration",
description="GenAI operation duration",
unit="s",
)
cost_counter = meter.create_counter(
name="gen_ai.client.cost",
description="Cost of GenAI operations",
unit="usd",
)
error_counter = meter.create_counter(
name="gen_ai.client.error.count",
description="GenAI operation errors",
unit="1",
)
retry_counter = meter.create_counter(
name="gen_ai.client.retry.count",
description="GenAI operation retries",
unit="1",
)
```
### Cost Calculation with Pricing Table
```python showLineNumbers title="src/content_quality/services/llm.py"
PRICING: dict[str, dict[str, float]] = {
# OpenAI (per million tokens)
"gpt-5.2": {"input": 1.75, "output": 14.0},
"gpt-4.1-mini": {"input": 0.40, "output": 1.60},
"gpt-4.1-nano": {"input": 0.10, "output": 0.40},
# Google Gemini
"gemini-3.0-flash-preview": {"input": 0.50, "output": 3.0},
"gemini-2.5-flash": {"input": 0.30, "output": 2.50},
# Anthropic
"claude-opus-4-6": {"input": 5.0, "output": 25.0},
"claude-sonnet-4-5-20250929": {"input": 3.0, "output": 15.0},
"claude-haiku-4-5-20251001": {"input": 1.0, "output": 5.0},
}
def _calculate_cost(
model: str, input_tokens: int, output_tokens: int
) -> float:
pricing = PRICING.get(model, {"input": 0.0, "output": 0.0})
return (
input_tokens * pricing["input"]
+ output_tokens * pricing["output"]
) / 1_000_000
```
### Evaluation and Quality Metrics
Track content quality scores as OpenTelemetry metrics and span events:
```python showLineNumbers title="src/content_quality/services/analyzer.py"
from opentelemetry import metrics, trace
from content_quality.services.llm import generate_structured
from content_quality.services.prompts import load_prompt
evaluation_score = metrics.get_meter("gen_ai.client").create_histogram(
name="gen_ai.evaluation.score",
description="Content quality evaluation score",
unit="1",
)
class ContentAnalyzer:
def __init__(self, llm):
self.llm = llm
settings = get_settings()
self._review_prompt = load_prompt(
f"review_{settings.review_prompt_version}"
)
self._score_prompt = load_prompt(
f"score_{settings.score_prompt_version}"
)
async def review(self, content, content_type="general"):
result = await generate_structured(
self.llm, PromptTemplate(self._review_prompt.user),
ReviewResult, content,
content_type=content_type, endpoint="/review",
system_prompt=self._review_prompt.system,
)
span = trace.get_current_span()
issue_score = max(
0,
100 - sum(
{"high": 3, "medium": 2, "low": 1}.get(i.severity, 1) * 10
for i in result.issues
),
)
span.add_event(
"gen_ai.evaluation.result",
{
"gen_ai.evaluation.name": "content_review",
"gen_ai.evaluation.score.value": issue_score,
"gen_ai.evaluation.score.label": (
"passed" if issue_score >= 60 else "failed"
),
"gen_ai.evaluation.explanation": result.summary,
},
)
evaluation_score.record(issue_score, {
"gen_ai.evaluation.name": "content_review",
"content.type": content_type,
})
return result
async def score(self, content, content_type="general"):
result = await generate_structured(
self.llm, PromptTemplate(self._score_prompt.user),
ScoreResult, content,
content_type=content_type, endpoint="/score",
system_prompt=self._score_prompt.system,
)
span = trace.get_current_span()
span.add_event(
"gen_ai.evaluation.result",
{
"gen_ai.evaluation.name": "content_quality",
"gen_ai.evaluation.score.value": result.score,
"gen_ai.evaluation.score.label": (
"passed" if result.score >= 60 else "failed"
),
"gen_ai.evaluation.explanation": result.summary,
},
)
evaluation_score.record(result.score, {
"gen_ai.evaluation.name": "content_quality",
"content.type": content_type,
})
return result
```
### YAML Prompt Management
Manage prompts as versioned YAML files for easy iteration:
```python showLineNumbers title="src/content_quality/services/prompts.py"
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
import yaml
PROMPTS_DIR = Path(__file__).resolve().parents[3] / "prompts"
@dataclass(frozen=True)
class PromptPair:
system: str
user: str
@lru_cache
def load_prompt(name: str) -> PromptPair:
path = PROMPTS_DIR / f"{name}.yaml"
with path.open() as f:
messages = yaml.safe_load(f)
system = ""
user = ""
for msg in messages:
text = msg["content"].replace("{{", "{").replace("}}", "}")
if msg["role"] == "system":
system = text
elif msg["role"] == "user":
user = text
return PromptPair(system=system, user=user)
```
### PII Scrubbing
Scrub PII from all content before recording in span events:
```python showLineNumbers title="src/content_quality/pii.py"
import re
_PII_PATTERNS = [
(re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"), "[EMAIL]"),
(re.compile(r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b"), "[PHONE]"),
(re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "[SSN]"),
(re.compile(r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b"), "[CARD]"),
(re.compile(
r"https?://(?:www\.)?linkedin\.com/in/[\w-]+"
), "[LINKEDIN]"),
]
def scrub_pii(text: str) -> str:
for pattern, replacement in _PII_PATTERNS:
text = pattern.sub(replacement, text)
return text
```
#### Content Capture Toggle
Content capture is opt-in via environment variable. When enabled, prompts and
completions are scrubbed and truncated before recording:
```python showLineNumbers title="src/content_quality/services/llm.py"
def _is_content_capture_enabled() -> bool:
return (
os.environ.get(
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", ""
).lower() == "true"
)
def _record_span_event(span, system_prompt, user_prompt, assistant_content):
attrs = {}
if system_prompt:
attrs["gen_ai.system_instructions"] = json.dumps(
[{"type": "text", "content": scrub_pii(system_prompt)[:500]}]
)
attrs["gen_ai.input.messages"] = json.dumps(
[{"role": "user", "parts": [
{"type": "text", "content": scrub_pii(user_prompt)[:500]}
]}]
)
attrs["gen_ai.output.messages"] = json.dumps(
[{"role": "assistant", "parts": [
{"type": "text", "content": scrub_pii(assistant_content)[:500]}
]}]
)
span.add_event("gen_ai.client.inference.operation.details", attrs)
```
### Eval-Driven Development with Promptfoo
Use Promptfoo to systematically test prompt quality and prevent regressions:
```yaml showLineNumbers title="promptfooconfig.yaml"
providers:
- id: python:evals/provider.py:review_provider
label: "Review Endpoint"
- id: python:evals/provider.py:improve_provider
label: "Improve Endpoint"
- id: python:evals/provider.py:score_provider
label: "Score Endpoint"
tests:
- vars:
content: "Our REVOLUTIONARY product is the BEST in the market!"
content_type: marketing
assert:
- type: is-json
- type: javascript
value: "file://evals/assertions/review.js"
- vars:
content: "PostgreSQL uses MVCC for concurrent transaction isolation."
content_type: technical
assert:
- type: is-json
- type: javascript
value: "file://evals/assertions/score.js"
```
Run evaluations:
```bash showLineNumbers title="Terminal"
npx promptfoo eval
npx promptfoo view
```
### FastAPI Application
```python showLineNumbers title="src/content_quality/main.py"
import asyncio
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from opentelemetry import trace
from opentelemetry.trace import StatusCode
from content_quality.config import get_settings
from content_quality.middleware import MetricsMiddleware
from content_quality.models.requests import ContentRequest
from content_quality.services.analyzer import ContentAnalyzer
from content_quality.services.llm import create_llm
from content_quality.telemetry import instrument_fastapi, setup_telemetry
settings = get_settings()
setup_telemetry(
service_name=settings.service_name,
otlp_endpoint=settings.otlp_endpoint,
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
app.state.llm = create_llm(
provider=settings.llm_provider,
model=settings.llm_model,
temperature=settings.llm_temperature,
api_key={
"openai": settings.openai_api_key,
"google": settings.google_api_key,
"anthropic": settings.anthropic_api_key,
}.get(settings.llm_provider, ""),
timeout=settings.llm_timeout,
)
app.state.analyzer = ContentAnalyzer(app.state.llm)
yield
app = FastAPI(title="AI Content Quality Agent", lifespan=lifespan)
app.add_middleware(MetricsMiddleware)
instrument_fastapi(app)
@app.get("/health")
async def health():
return {"status": "healthy", "service": settings.service_name}
@app.post("/review")
async def review_content(request: ContentRequest):
try:
return await asyncio.wait_for(
app.state.analyzer.review(request.content, request.content_type),
timeout=settings.request_timeout,
)
except TimeoutError:
raise HTTPException(status_code=504, detail="Analysis timed out")
except Exception:
raise HTTPException(status_code=502, detail="Analysis failed")
@app.post("/improve")
async def improve_content(request: ContentRequest):
try:
return await asyncio.wait_for(
app.state.analyzer.improve(request.content, request.content_type),
timeout=settings.request_timeout,
)
except TimeoutError:
raise HTTPException(status_code=504, detail="Analysis timed out")
except Exception:
raise HTTPException(status_code=502, detail="Analysis failed")
@app.post("/score")
async def score_content(request: ContentRequest):
try:
return await asyncio.wait_for(
app.state.analyzer.score(request.content, request.content_type),
timeout=settings.request_timeout,
)
except TimeoutError:
raise HTTPException(status_code=504, detail="Analysis timed out")
except Exception:
raise HTTPException(status_code=502, detail="Analysis failed")
```
### Running Your Application
```mdx-code-block
```
```bash showLineNumbers
uv run uvicorn content_quality.main:app --reload --host 0.0.0.0 --port 8000
```
```mdx-code-block
```
```bash showLineNumbers
OTEL_SDK_DISABLED=false \
LLM_PROVIDER=anthropic \
LLM_MODEL=claude-sonnet-4-5-20250929 \
OTLP_ENDPOINT=http://collector:4318 \
uv run uvicorn content_quality.main:app --host 0.0.0.0 --port 8000
```
```mdx-code-block
```
```bash showLineNumbers
docker compose up --build
curl http://localhost:8000/health
docker compose down
```
```mdx-code-block
```
### Troubleshooting
#### Verify Telemetry Is Working
```bash showLineNumbers
curl http://localhost:13133
# Open http://localhost:55679/debug/tracez for zpages
```
#### Enable Debug Mode
```python showLineNumbers
import logging
logging.getLogger("opentelemetry").setLevel(logging.DEBUG)
```
#### Common Issues
##### Issue: Token counts are zero
**Solutions:**
1. Check your LLM SDK version - older versions may not expose `usage`
2. Verify the provider response has `input_tokens` and `output_tokens`
3. For Google GenAI, check `response.usage_metadata` instead of `response.usage`
##### Issue: Cost metrics not accurate
**Solutions:**
1. Verify your `PRICING` dictionary contains the exact model ID string returned
by the provider (e.g., `gpt-4.1-nano`, not `gpt-4.1`)
2. Check that cost is calculated with `/1_000_000` (pricing is per million
tokens)
##### Issue: Structured output validation fails repeatedly
**Solutions:**
1. Verify your Pydantic model has proper `Field` descriptions
2. Check that `MAX_PARSE_RETRIES` is set to at least 2
3. Try adding `response_mime_type: "application/json"` for Google models
##### Issue: Spans not exported to Scout
**Solutions:**
1. Confirm collector: `curl http://localhost:13133`
2. Check collector logs: `docker compose logs otel-collector`
3. Verify `OTLP_ENDPOINT` points to the collector, not directly to Scout
### Security Considerations
#### Protecting Sensitive Data
- **Never record raw prompts** that may contain user data, API keys, or
credentials in span attributes or events
- **Truncate content** to 500 characters to avoid oversized spans
- **Disable content capture** in production if compliance requires it - set
`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=false`
- **Scrub PII** before recording any content in telemetry (see
[PII Scrubbing](#pii-scrubbing) for the regex patterns used)
#### Compliance Considerations
For applications handling regulated data (GDPR, HIPAA, PCI-DSS):
- Use opt-in content capture - disabled by default in this guide
- Record only token counts and model metadata, not prompt content
- Audit span attributes regularly for sensitive data leaks
- Use the OTel Collector `attributes` processor to redact fields before export
if additional filtering is needed
### Performance Considerations
OpenTelemetry overhead is negligible relative to LLM API latency. A typical LLM
call takes 1-5 seconds; span creation adds microseconds.
#### Optimization Strategies
##### 1. Use BatchSpanProcessor
```python showLineNumbers
trace_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint=endpoint),
max_queue_size=2048,
max_export_batch_size=512,
)
)
```
##### 2. Truncate Content Events
Always truncate prompts and completions to keep span sizes reasonable:
```python showLineNumbers
scrub_pii(prompt)[:500] # 500 chars max
```
##### 3. Disable Content Capture in High-Volume Scenarios
```bash showLineNumbers
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=false
```
### FAQ
#### Why use custom instrumentation instead of OpenInference?
OpenInference produces non-standard attributes (`llm.*`, `input.*`, `output.*`)
that are specific to LlamaIndex and don't follow the OpenTelemetry GenAI
semantic conventions. Custom instrumentation gives you standard attributes
(`gen_ai.*`) that work with any OTel-compatible backend.
#### Does OpenTelemetry add latency to LLM calls?
No. Span creation takes microseconds. LLM API calls take seconds. The overhead
is unmeasurable. `BatchSpanProcessor` exports in a background thread.
#### How do I track cost across multiple LLM providers?
Use the `gen_ai.client.cost` counter metric with `gen_ai.provider.name` and
`gen_ai.request.model` attributes. Define pricing per model and calculate from
token counts.
#### How does structured output retry work in LlamaIndex with OpenTelemetry?
When the LLM returns invalid JSON, the system appends the validation error to
the conversation and asks the LLM to retry. This happens up to
`MAX_PARSE_RETRIES` times (default 2) before raising an error.
#### Can I see prompts and completions in traces?
Yes, if `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`. Content is
PII-scrubbed and truncated to 500 characters. Disable in production for
compliance.
#### How do I add a custom LLM provider to a LlamaIndex application?
Add the provider to `create_llm()`, add its server address to
`PROVIDER_SERVERS`, and add its model pricing to the `PRICING` dictionary.
#### How do I version and manage prompts in a LlamaIndex project?
Prompts are stored as YAML files in the `prompts/` directory with version
suffixes (e.g., `review_v1.yaml`, `review_v2.yaml`). The active version is
configured via `REVIEW_PROMPT_VERSION` environment variable.
#### How do I evaluate LLM prompts with Promptfoo and LlamaIndex?
Install Promptfoo (`npm install -g promptfoo`), then run `npx promptfoo eval`
from the project root. Results can be viewed with `npx promptfoo view`.
#### How do I remove PII from OpenTelemetry traces in Python?
Regex patterns detect and replace emails, phone numbers, SSNs, credit card
numbers, and LinkedIn URLs with safe placeholders before any content is recorded
in span events.
#### Can I use this with RAG systems?
Yes. The patterns here (GenAI spans, token tracking, cost metrics) apply to any
LlamaIndex application. For RAG, add spans around your retrieval step with
attributes like `retrieval.document_count` and `retrieval.strategy`.
### What's Next?
#### Related Guides
- [FastAPI Instrumentation](./fast-api.md) - Common API host for RAG services
- [Python Custom Instrumentation](../custom-instrumentation/python.md) - Manual
spans and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
#### Scout Platform Features
- [Creating Alerts](../../../guides/creating-alerts-with-logx.md) - Alert on
cost spikes, error rates, or quality degradation
- [Dashboard Creation](../../../guides/create-your-first-dashboard.md) - Build
dashboards for token usage, cost attribution, and evaluation scores
#### Deployment and Operations
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) -
Local development with the OTel Collector
### Complete Example
#### Project Structure
```text showLineNumbers
ai-content-quality/
├── src/content_quality/
│ ├── main.py # FastAPI app with lifespan
│ ├── config.py # Pydantic Settings
│ ├── telemetry.py # OTel initialization
│ ├── pii.py # PII scrubbing
│ ├── models/
│ │ ├── requests.py # ContentRequest schema
│ │ └── responses.py # ReviewResult, ImproveResult, ScoreResult
│ ├── services/
│ │ ├── llm.py # GenAI spans, metrics, structured output
│ │ ├── analyzer.py # Content analysis with evaluation metrics
│ │ └── prompts.py # YAML prompt loader
│ └── middleware/
│ └── metrics.py # HTTP metrics middleware
├── prompts/
│ ├── review_v1.yaml # Review prompt v1
│ ├── review_v2.yaml # Review prompt v2
│ ├── improve_v1.yaml # Improve prompt
│ └── score_v1.yaml # Score prompt
├── evals/
│ ├── assertions/ # Promptfoo assertion scripts
│ └── datasets/ # Test case datasets
├── promptfooconfig.yaml # Promptfoo eval configuration
├── otel-collector-config.yaml
├── compose.yml
├── Dockerfile
└── pyproject.toml
```
#### Key Files
| File | Demonstrates |
| -------------- | -------------------------------------------------- |
| `telemetry.py` | OTel setup (traces + metrics + logs) |
| `llm.py` | GenAI spans, token/cost metrics, structured output |
| `analyzer.py` | Evaluation events and quality metrics |
| `prompts.py` | YAML prompt management with versioning |
| `pii.py` | PII scrubbing before telemetry recording |
| `config.py` | Multi-provider settings with Pydantic |
| `compose.yml` | Docker deployment with OTel Collector |
#### GitHub Repository
For a complete working example, see the
[AI Content Quality Agent](https://github.com/base-14/examples/tree/main/python/ai-content-quality)
repository.
### References
- [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/)
- [OpenTelemetry Python SDK](https://opentelemetry.io/docs/languages/python/)
- [LlamaIndex Documentation](https://docs.llamaindex.ai/)
- [Promptfoo Documentation](https://promptfoo.dev/docs/)
---
## Micronaut OpenTelemetry Instrumentation - Java Agent, Hibernate & Netty Tracing
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
Implement OpenTelemetry instrumentation for Micronaut applications using the
OpenTelemetry Java Agent for zero-code distributed tracing, Hibernate JPA
query monitoring, and structured log correlation. The Java Agent attaches to
the JVM at startup and automatically instruments HTTP requests, JDBC queries,
Netty server operations, and outgoing HTTP client calls without any code
changes.
Micronaut is a compile-time dependency-injection JVM framework. For the dominant
ecosystem alternative see [Spring Boot](./spring-boot.md), and for a Kotlin-first
option see [Ktor](./ktor.md).
Micronaut applications benefit from the Java Agent's comprehensive coverage
of the JVM ecosystem including Hibernate, HikariCP connection pools, Java
HTTP clients, and Netty. With OpenTelemetry, you can monitor production
performance, debug slow requests, trace distributed transactions across
microservices, and correlate logs with traces using a single `-javaagent`
flag.
Whether you're implementing observability for the first time, migrating from
commercial APM solutions, or troubleshooting performance issues in production,
this guide provides production-ready configurations for Micronaut
OpenTelemetry instrumentation.
> **Note:** This guide provides a practical Micronaut-focused overview based
> on the official OpenTelemetry documentation. For complete Java agent
> information, please consult the
> [official OpenTelemetry Java documentation](https://opentelemetry.io/docs/languages/java/).
:::tip TL;DR
Download the OpenTelemetry Java Agent JAR, set
`JAVA_TOOL_OPTIONS="-javaagent:/path/to/opentelemetry-javaagent.jar"`, and
configure `OTEL_SERVICE_NAME` + `OTEL_EXPORTER_OTLP_ENDPOINT`. HTTP
requests, Hibernate queries, Netty I/O, and HTTP client calls are traced
automatically with zero code changes. The agent injects `trace_id` and
`span_id` into SLF4J MDC for log correlation.
:::
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Who This Guide Is For
This documentation is designed for:
- **Micronaut developers**: implementing observability and distributed
tracing for the first time
- **Cloud-native teams**: running Micronaut microservices in Kubernetes or
Docker
- **DevOps engineers**: deploying JVM applications with production monitoring
requirements
- **Engineering teams**: migrating from Datadog, New Relic, or other
commercial APM solutions
- **Platform teams**: standardizing observability across JVM services
(Micronaut, Spring Boot, Quarkus)
### Overview
This guide demonstrates how to:
- Attach the OpenTelemetry Java Agent to Micronaut applications for zero-code
instrumentation
- Configure trace export to Scout Collector via environment variables
- Set up structured JSON logging with automatic trace context correlation
- Wire custom metrics and spans using the OpenTelemetry API
- Deploy instrumented applications with Docker Compose (app + notify +
PostgreSQL + collector)
- Trace requests across multiple Micronaut services (distributed tracing)
- Troubleshoot common instrumentation issues
### Prerequisites
Before starting, ensure you have:
- **Java 17 or later** (Java 21+ recommended for best performance)
- Eclipse Temurin or any OpenJDK distribution
- **Micronaut 3.x or 4.x** installed
- Micronaut 4.x is recommended for optimal compatibility
- **Gradle 8.x or Maven 3.9+** for build management
- **Scout Collector** configured and accessible
- See
[Docker Compose Setup](../../collector-setup/docker-compose-example.md)
for local development
- Basic understanding of OpenTelemetry concepts (traces, spans, attributes)
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version |
| ---------------------- | --------------- | ------------------- |
| Java | 17 | 21+ |
| Micronaut | 3.0.0 | 4.8.0+ |
| Gradle | 8.0 | 8.10+ |
| OpenTelemetry Java Agent | 1.0.0 | 2.26+ |
| Hibernate ORM | 5.6.0 | 6.6.0+ |
| PostgreSQL Driver | 42.5.0 | Latest stable |
#### Instrumented Components (Automatic)
The Java Agent instruments these components with zero code changes:
| Component | Coverage |
| --------------------- | ------------------------------------------------- |
| Micronaut HTTP Server | Routes, controllers, request/response attributes |
| Netty | Server I/O, connection handling |
| Hibernate / JDBC | All SQL queries, transactions, connection pools |
| Java HTTP Client | Outgoing HTTP calls, W3C trace propagation |
| HikariCP | Connection pool metrics |
| Logback | MDC injection of trace_id and span_id |
| Flyway | Database migration spans |
#### Example Application
This guide references the
[micronaut-postgres](https://github.com/base-14/examples/tree/main/java/micronaut-postgres)
example: a Micronaut 4.8 REST API with Hibernate JPA, a notification
microservice, and full OpenTelemetry instrumentation.
### Installation
#### Step 1: Download the OpenTelemetry Java Agent
Download the latest agent JAR from the official releases:
```bash
# Download the agent (v2.26.1)
curl -L -o opentelemetry-javaagent.jar \
https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.26.1/opentelemetry-javaagent.jar
```
#### Step 2: Attach the Agent to the JVM
The agent attaches via the `-javaagent` JVM flag. The simplest approach is
the `JAVA_TOOL_OPTIONS` environment variable:
```bash
export JAVA_TOOL_OPTIONS="-javaagent:/path/to/opentelemetry-javaagent.jar"
```
This works regardless of how you start your application (Gradle, Maven, java
-jar, etc.).
#### Step 3: Add the OpenTelemetry API Dependency (Optional)
For custom metrics and manual spans, add the OpenTelemetry API to your
build. This is optional if you only need automatic instrumentation:
```kotlin title="build.gradle.kts" showLineNumbers
dependencies {
// Required: Micronaut core
implementation("io.micronaut:micronaut-http-client")
implementation("io.micronaut.serde:micronaut-serde-jackson")
implementation("io.micronaut.data:micronaut-data-hibernate-jpa")
implementation("io.micronaut.sql:micronaut-jdbc-hikari")
implementation("io.micronaut.flyway:micronaut-flyway")
// Optional: OTel API for custom metrics and spans
implementation("io.opentelemetry:opentelemetry-api:1.48.0")
// Runtime
runtimeOnly("org.postgresql:postgresql")
runtimeOnly("ch.qos.logback:logback-classic")
runtimeOnly("net.logstash.logback:logstash-logback-encoder:8.0")
}
```
The `opentelemetry-api` dependency is a compile-time-only API. The Java Agent
provides the implementation at runtime, so there is no version conflict.
### Configuration
```mdx-code-block
```
Configure the agent entirely through environment variables:
```bash title=".env"
# OpenTelemetry Java Agent
OTEL_SERVICE_NAME=micronaut-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development,environment=development
```
The agent reads these variables at startup and configures itself. No
application code or config files need to change.
```mdx-code-block
```
Micronaut-specific configuration for the application itself (database,
HTTP client, Flyway):
```yaml title="src/main/resources/application.yml" showLineNumbers
micronaut:
application:
name: micronaut-articles
server:
port: 8080
datasources:
default:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:micronaut}
username: ${DB_USER:postgres}
password: ${DB_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
jpa:
default:
entity-scan:
packages:
- com.example.model
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
hbm2ddl:
auto: validate
flyway:
datasources:
default:
enabled: true
locations: classpath:db/migration
notify:
url: ${NOTIFY_URL:`http://localhost:8081`}
```
The Java Agent instruments Hibernate and JDBC automatically regardless of
how you configure the datasource.
```mdx-code-block
```
Run the full observability stack locally with Docker Compose:
```yaml title="compose.yml" showLineNumbers
x-otel-env: &otel-env
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
OTEL_TRACES_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_LOGS_EXPORTER: otlp
OTEL_METRIC_EXPORT_INTERVAL: "10000"
OTEL_RESOURCE_ATTRIBUTES: deployment.environment=development,environment=development
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.148.0
container_name: micronaut-otel-collector
command: ["--config=/etc/otelcol-contrib/config.yaml"]
ports:
- "4317:4317"
- "4318:4318"
volumes:
- ./config/otel-config.yaml:/etc/otelcol-contrib/config.yaml
environment:
- SCOUT_ENDPOINT=${SCOUT_ENDPOINT:-http://localhost:4318}
- SCOUT_CLIENT_ID=${SCOUT_CLIENT_ID:-}
- SCOUT_CLIENT_SECRET=${SCOUT_CLIENT_SECRET:-}
- SCOUT_TOKEN_URL=${SCOUT_TOKEN_URL:-}
restart: unless-stopped
db:
image: postgres:18-alpine
container_name: micronaut-postgres
environment:
POSTGRES_DB: micronaut
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
app:
build:
context: ./app
dockerfile: Dockerfile
container_name: micronaut-app
ports:
- "${APP_PORT:-8080}:8080"
environment:
<<: *otel-env
OTEL_SERVICE_NAME: micronaut-articles
DB_HOST: db
DB_PORT: "5432"
DB_NAME: micronaut
DB_USER: postgres
DB_PASSWORD: postgres
NOTIFY_URL: http://notify:8081
depends_on:
db:
condition: service_healthy
otel-collector:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"]
interval: 10s
timeout: 5s
start_period: 30s
retries: 5
restart: unless-stopped
volumes:
pgdata:
```
The YAML anchor `&otel-env` shares OpenTelemetry environment variables
across services.
```mdx-code-block
```
#### Configure Structured Logging
Set up Logback with JSON output. The Java Agent automatically injects
`trace_id` and `span_id` into the SLF4J MDC, so all you need is a JSON
encoder that includes MDC fields:
```xml title="src/main/resources/logback.xml" showLineNumbers
trace_id
span_id
```
Every log line now includes `trace_id` and `span_id` in the JSON output,
enabling you to jump from a log entry in Scout directly to the
corresponding trace.
#### Scout Collector Integration
Configure trace export to Scout with OAuth2 authentication:
```bash title=".env"
# Scout Collector Configuration
SCOUT_ENDPOINT=https://your-tenant.base14.io/v1/traces
SCOUT_CLIENT_ID=your_client_id
SCOUT_CLIENT_SECRET=your_client_secret
SCOUT_TOKEN_URL=https://your-tenant.base14.io/oauth/token
# Service Configuration
OTEL_SERVICE_NAME=micronaut-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
```
> **Scout Dashboard Integration**: After configuration, your traces will
> appear in the Scout Dashboard. Navigate to the Traces section to view
> request flows, identify bottlenecks, and analyze distributed transactions.
### Production Configuration
Production deployments require tuning for performance, reliability, and
resource utilization.
#### Production Environment Variables
```bash title=".env.production"
# OpenTelemetry Java Agent
OTEL_SERVICE_NAME=micronaut-app
OTEL_SERVICE_VERSION=2.1.3
# Scout Collector Endpoint
OTEL_EXPORTER_OTLP_ENDPOINT=https://scout-collector.example.com/v1/traces
SCOUT_CLIENT_ID=prod_client_id
SCOUT_CLIENT_SECRET=prod_secret_key
SCOUT_TOKEN_URL=https://scout-collector.example.com/oauth/token
# Exporter Settings
OTEL_TRACES_EXPORTER=otlp
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_COMPRESSION=gzip
OTEL_EXPORTER_OTLP_TIMEOUT=10000
# Batch Span Processor (Production Optimized)
OTEL_BSP_MAX_QUEUE_SIZE=2048
OTEL_BSP_SCHEDULE_DELAY=5000
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
# Metric Export Interval
OTEL_METRIC_EXPORT_INTERVAL=30000
# Resource Attributes
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=demo,environment=demo,host.name=${HOSTNAME}
```
#### Docker Production Configuration
Multi-stage Dockerfile that builds a shadow JAR and bakes in the
OpenTelemetry Java Agent:
```dockerfile title="Dockerfile" showLineNumbers
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /app
COPY gradle/ gradle/
COPY gradlew settings.gradle.kts build.gradle.kts ./
RUN chmod +x gradlew && ./gradlew dependencies --no-daemon
COPY src/ src/
RUN ./gradlew shadowJar --no-daemon
FROM eclipse-temurin:25-jre
WORKDIR /app
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
ARG OTEL_AGENT_VERSION=2.26.1
ADD https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${OTEL_AGENT_VERSION}/opentelemetry-javaagent.jar /app/opentelemetry-javaagent.jar
RUN addgroup --gid 1001 appgroup && \
adduser --uid 1001 --gid 1001 --disabled-password --gecos "" appuser && \
chown appuser:appgroup /app/opentelemetry-javaagent.jar
COPY --from=builder --chown=appuser:appgroup /app/build/libs/*-all.jar /app/app.jar
USER appuser
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-javaagent:/app/opentelemetry-javaagent.jar"
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
```
Key details:
- **Multi-stage build** separates Gradle build from runtime image
- **OTel Java Agent** downloaded and baked into the image via `ADD`
- **`JAVA_TOOL_OPTIONS`** attaches the agent automatically on every JVM start
- **Non-root user** (`appuser:1001`) for security
- **Shadow JAR** bundles all dependencies into a single executable JAR
#### Multi-Service Distributed Tracing
For architectures with multiple services, each gets its own
`OTEL_SERVICE_NAME`. The Java Agent automatically propagates W3C
`traceparent` headers on outgoing HTTP requests.
Here's the notification client from the example app:
```java title="src/main/java/com/example/service/NotificationClient.java" showLineNumbers
package com.example.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micronaut.context.annotation.Value;
import jakarta.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
@Singleton
public class NotificationClient {
private static final Logger LOG = LoggerFactory.getLogger(NotificationClient.class);
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
private final String notifyUrl;
public NotificationClient(
@Value("${notify.url:`http://localhost:8081`}") String notifyUrl) {
this.notifyUrl = notifyUrl;
}
public void notify(Map payload) {
try {
String json = objectMapper.writeValueAsString(payload);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(notifyUrl + "/notify"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse response = httpClient.send(
request, HttpResponse.BodyHandlers.ofString());
LOG.debug("Notify response: {}", response.statusCode());
} catch (Exception e) {
LOG.warn("Failed to notify: {}", e.getMessage());
}
}
}
```
The Java Agent instruments `java.net.http.HttpClient` automatically, injecting
the `traceparent` header into the outgoing request. No code changes needed.
Add the notification service to Docker Compose:
```yaml title="compose.yml (excerpt)"
services:
app:
environment:
OTEL_SERVICE_NAME: micronaut-articles
NOTIFY_URL: http://notify:8081
notify:
build:
context: ./notify
environment:
<<: *otel-env
OTEL_SERVICE_NAME: micronaut-notify
ports:
- "8081:8081"
```
In Scout Dashboard, you'll see the full distributed trace:
```plaintext
micronaut-articles: POST /api/articles
+-- INSERT INTO articles ...
+-- POST http://notify:8081/notify
+-- micronaut-notify: POST /notify (linked trace)
```
### Micronaut-Specific Features
#### Automatic HTTP Request Tracing
The Java Agent instruments Micronaut's Netty-based HTTP server automatically.
Every request creates a root span with:
- `http.method` - Request method (GET, POST, etc.)
- `http.route` - Matched route pattern (e.g., `/api/articles/{id}`)
- `http.status_code` - Response status code
- `url.path` - Request URI path
Micronaut controller annotations map directly to span names:
```java
@Controller("/api/articles")
@ExecuteOn(TaskExecutors.BLOCKING)
public class ArticleController {
@Get
public HttpResponse> list() {
// Auto-instrumented: creates span "GET /api/articles"
}
@Get("/{id}")
public HttpResponse> get(@PathVariable Long id) {
// Auto-instrumented: creates span "GET /api/articles/{id}"
// Uses route pattern, not the actual ID (low cardinality)
}
@Post
public HttpResponse> create(@Body CreateArticleRequest request) {
// Auto-instrumented: creates span "POST /api/articles"
}
}
```
#### Hibernate JPA Query Tracing
All Hibernate queries are traced automatically via JDBC instrumentation.
Each query creates a span with:
- `db.system` - Database type (`postgresql`)
- `db.name` - Database name
- `db.statement` - SQL query (parameters obfuscated)
- `db.operation` - Operation type (`SELECT`, `INSERT`, `UPDATE`, `DELETE`)
```java
// These are all automatically traced:
// Micronaut Data repository query
Page result = articleRepository.findAll(Pageable.from(0, 10));
// Direct entity operations
Article article = articleRepository.save(newArticle);
articleRepository.deleteById(id);
```
In Scout Dashboard, you'll see spans like:
```plaintext
SELECT a1_0.id, ... FROM articles a1_0 (db.system=postgresql, db.operation=SELECT)
INSERT INTO articles ... (db.system=postgresql, db.operation=INSERT)
```
#### Flyway Migration Tracing
Database migrations executed by Flyway during application startup are
automatically traced. Each migration file creates a span, giving you
visibility into startup time.
#### Logback Trace-Log Correlation
The Java Agent automatically injects `trace_id` and `span_id` into the
SLF4J MDC (Mapped Diagnostic Context). Combined with the
`logstash-logback-encoder`, every JSON log line includes trace context:
```json
{
"message": "Article created: id=42, title=Hello",
"logger_name": "com.example.controller.ArticleController",
"level": "INFO",
"trace_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"span_id": "1a2b3c4d5e6f7a8b"
}
```
This is fully automatic - no custom processors or MDC manipulation needed.
The agent handles MDC injection, and `logstash-logback-encoder` handles
JSON formatting.
#### Micronaut Dependency Injection
Micronaut's compile-time dependency injection works seamlessly with the
OpenTelemetry API. Use `@Singleton` and `@Value` for service wiring:
```java
@Singleton
public class NotificationClient {
public NotificationClient(
@Value("${notify.url}") String notifyUrl) {
// Micronaut injects the value at compile time
// The Java Agent instruments HTTP calls at runtime
}
}
```
No special OpenTelemetry configuration in `services.yaml` or
`application.yml` is needed. The agent provides everything at the JVM level.
### Custom Instrumentation
While the Java Agent covers HTTP, JDBC, and Netty automatically, you can
add custom metrics and spans for business logic.
#### Custom Business Metrics
Create a telemetry service to register and increment custom counters:
```java title="src/main/java/com/example/service/TelemetryService.java" showLineNumbers
package com.example.service;
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.metrics.LongCounter;
import io.opentelemetry.api.metrics.Meter;
import jakarta.annotation.PostConstruct;
import jakarta.inject.Singleton;
@Singleton
public class TelemetryService {
private LongCounter articlesCreated;
@PostConstruct
void init() {
Meter meter = GlobalOpenTelemetry.getMeter("micronaut-articles");
articlesCreated = meter.counterBuilder("articles.created")
.setDescription("Total number of articles created")
.build();
}
public void incrementArticlesCreated() {
articlesCreated.add(1);
}
}
```
Use it in your controller:
```java title="src/main/java/com/example/controller/ArticleController.java (excerpt)"
@Post
public HttpResponse> create(@Body CreateArticleRequest request) {
Article article = new Article();
article.setTitle(request.title());
article.setBody(request.body());
article = articleRepository.save(article);
LOG.info("Article created: id={}, title={}", article.getId(), article.getTitle());
telemetryService.incrementArticlesCreated();
try {
notificationClient.notify(Map.of(
"id", article.getId(),
"title", article.getTitle(),
"event", "article.created"
));
} catch (Exception e) {
LOG.warn("Failed to notify: {}", e.getMessage());
}
return HttpResponse.status(HttpStatus.CREATED).body(Map.of(
"data", article,
"meta", Map.of("trace_id", currentTraceId())
));
}
```
#### Including Trace ID in API Responses
Include the trace ID in API responses so clients can correlate their
requests with backend traces:
```java
private String currentTraceId() {
return Span.current().getSpanContext().getTraceId();
}
```
Every response includes `"trace_id"` in the `meta` field, making it easy
to look up the corresponding trace in Scout Dashboard.
#### Manual Span Creation
Create custom spans for business-critical operations not covered by
automatic instrumentation:
```java title="src/main/java/com/example/service/ReportService.java" showLineNumbers
package com.example.service;
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;
import jakarta.inject.Singleton;
@Singleton
public class ReportService {
private final Tracer tracer = GlobalOpenTelemetry.getTracer(
"report-service", "1.0.0");
public byte[] generateReport(Long userId, String reportType) {
Span span = tracer.spanBuilder("generate_report")
.setSpanKind(SpanKind.INTERNAL)
.setAttribute("report.type", reportType)
.setAttribute("user.id", userId)
.startSpan();
try (Scope scope = span.makeCurrent()) {
byte[] report = buildReport(userId, reportType);
span.setAttribute("report.size_bytes", report.length);
span.setStatus(StatusCode.OK);
return report;
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR, e.getMessage());
throw e;
} finally {
span.end();
}
}
}
```
### Running Your Instrumented Application
#### Development Mode
Run locally with Gradle and the Java Agent:
```bash
# Download the agent (one-time)
curl -L -o opentelemetry-javaagent.jar \
https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.26.1/opentelemetry-javaagent.jar
# Set environment variables
export JAVA_TOOL_OPTIONS="-javaagent:./opentelemetry-javaagent.jar"
export OTEL_SERVICE_NAME=micronaut-app-dev
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# Run with Gradle
./gradlew run
```
#### Docker Deployment
Run the full stack with Docker Compose:
```bash
# Start all services (app, database, collector)
docker compose up --build
# Wait for services to be healthy (~30 seconds)
docker compose ps
# Verify the app is running
curl http://localhost:8080/api/health
```
Expected health check response:
```json
{ "status": "healthy", "database": "connected" }
```
#### Verifying Instrumentation
Make test requests and check that traces appear:
```bash
# Create an article
curl -X POST http://localhost:8080/api/articles \
-H "Content-Type: application/json" \
-d '{"title": "Hello OpenTelemetry", "body": "Tracing with Micronaut"}'
# List articles
curl http://localhost:8080/api/articles
# Get a specific article
curl http://localhost:8080/api/articles/1
```
The expected span hierarchy for a create request:
```plaintext
POST /api/articles (SERVER - java-agent)
+-- HikariCP getConnection (INTERNAL - java-agent)
+-- INSERT INTO articles ... (CLIENT - java-agent/jdbc)
+-- POST http://notify:8081/notify (CLIENT - java-agent/http)
+-- micronaut-notify: POST /notify (SERVER - java-agent)
```
For a list request:
```plaintext
GET /api/articles (SERVER - java-agent)
+-- HikariCP getConnection (INTERNAL - java-agent)
+-- SELECT a1_0.id, ... FROM articles (CLIENT - java-agent/jdbc)
+-- SELECT COUNT(*) FROM articles (CLIENT - java-agent/jdbc)
```
Check for:
- **Spans** with correct `service.name` and proper nesting
- **Logs** with `trace_id` and `span_id` in the JSON output
- **Metrics** with `articles.created` counter incrementing
### Troubleshooting
#### Verifying Agent Attachment
```bash
# Check that the agent is loaded (look for OpenTelemetry in startup logs)
docker compose logs app | grep -i "opentelemetry"
# Verify JAVA_TOOL_OPTIONS is set
docker compose exec app env | grep JAVA_TOOL_OPTIONS
```
You should see a line like:
```plaintext
[otel.javaagent] opentelemetry-javaagent - version: 2.26.1
```
#### Common Issues
##### Issue: No traces appearing in Scout Dashboard
**Solutions:**
1. Verify the agent JAR is present and attached:
```bash
ls -la /app/opentelemetry-javaagent.jar
echo $JAVA_TOOL_OPTIONS
```
2. Check that the collector endpoint is reachable:
```bash
curl -v http://otel-collector:4318/v1/traces
```
3. Enable debug logging on the agent:
```bash
export OTEL_JAVAAGENT_DEBUG=true
```
4. Check collector logs for authentication errors:
```bash
docker compose logs otel-collector
```
##### Issue: No JDBC/Hibernate query spans
**Solutions:**
1. Verify the agent is attached (see above). JDBC instrumentation is
included in the agent by default.
2. Check that the database connection is working:
```bash
curl http://localhost:8080/api/health
# Should return {"status":"healthy","database":"connected"}
```
3. Ensure you're not using a database driver that the agent doesn't
support. PostgreSQL, MySQL, and H2 are all supported.
##### Issue: No trace context propagation between services
**Solutions:**
1. Verify both services have the Java Agent attached. Check startup logs
for both containers:
```bash
docker compose logs app | head -20
docker compose logs notify | head -20
```
2. Confirm `OTEL_PROPAGATORS` includes `tracecontext` (this is the default):
```bash
echo $OTEL_PROPAGATORS
# Should be empty (defaults) or include "tracecontext"
```
3. Ensure the HTTP client being used is instrumented. The standard
`java.net.http.HttpClient` is supported. If using a different client,
check the
[agent supported libraries](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md).
##### Issue: Log correlation not working (missing trace_id in logs)
**Solutions:**
1. Verify `logstash-logback-encoder` is in your dependencies:
```bash
./gradlew dependencies | grep logstash
```
2. Check that `logback.xml` uses `LogstashEncoder`:
```xml
trace_id
span_id
```
3. If using a custom Logback pattern instead of JSON, include MDC fields:
```xml
%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} [trace=%X{trace_id}] - %msg%n
```
##### Issue: High memory usage
**Solutions:**
1. Reduce the batch queue size:
```bash
export OTEL_BSP_MAX_QUEUE_SIZE=1024
```
2. Increase export frequency to flush spans sooner:
```bash
export OTEL_BSP_SCHEDULE_DELAY=2000
```
3. Set JVM heap limits appropriate for your workload:
```bash
export JAVA_TOOL_OPTIONS="-javaagent:/app/opentelemetry-javaagent.jar -Xmx512m"
```
### Security Considerations
#### SQL Parameter Obfuscation
The Java Agent automatically obfuscates SQL parameter values in database
spans:
```sql
-- What gets executed (never sent to collector)
SELECT * FROM users WHERE email = 'user@example.com' AND api_key = 'sk-abc123'
-- What appears in the span (obfuscated)
SELECT * FROM users WHERE email = ? AND api_key = ?
```
This is enabled by default and requires no configuration.
#### Protecting Sensitive Data
Never add sensitive information to span attributes:
```java
// Bad - exposes sensitive data
span.setAttribute("user.password", user.getPassword()); // Never!
span.setAttribute("user.email", user.getEmail()); // PII risk
span.setAttribute("payment.card", request.getCreditCard()); // Never!
// Good - uses safe identifiers
span.setAttribute("user.id", user.getId());
span.setAttribute("user.role", user.getRole());
span.setAttribute("payment.status", "completed");
```
#### Filtering Sensitive HTTP Headers
Configure which HTTP headers the agent captures:
```bash title=".env"
# Only capture safe request headers
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST=content-type,accept,user-agent
# Block sensitive headers (excluded by default, but explicit is safer)
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE=content-type
```
#### Compliance Considerations
For applications handling regulated data (GDPR, HIPAA, PCI-DSS):
- SQL obfuscation is enabled by default
- Never include personally identifiable information (PII) in spans
- Use hashed or anonymized user identifiers
- Implement data retention policies in Scout Dashboard
- Audit span attributes regularly for sensitive data leaks
### Performance Considerations
#### Expected Performance Impact
The OpenTelemetry Java Agent adds minimal overhead to Micronaut applications:
- **Average latency increase**: 3-5ms per request
- **CPU overhead**: Less than 5% with batch processing
- **Memory overhead**: ~50-80MB for the agent itself
- **Startup time**: ~1-3 seconds additional for agent initialization
#### Optimization Best Practices
##### 1. Use Batch Span Processing
```bash
# Production settings (low overhead)
OTEL_BSP_MAX_QUEUE_SIZE=2048
OTEL_BSP_SCHEDULE_DELAY=5000
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
```
##### 2. Enable GZIP Compression
```bash
OTEL_EXPORTER_OTLP_COMPRESSION=gzip
```
Reduces network bandwidth by 70-80%.
##### 3. Tune Metric Export Interval
```bash
# Default is 60s; 30s provides better granularity
OTEL_METRIC_EXPORT_INTERVAL=30000
```
##### 4. Filter Health Check Endpoints
Configure the OTel Collector to drop noisy health check spans:
```yaml title="config/otel-config.yaml (excerpt)"
processors:
filter/noisy:
error_mode: ignore
traces:
span:
- 'IsMatch(name, ".*health.*")'
```
##### 5. Disable Unused Instrumentation
If you don't need specific instrumentations, disable them:
```bash
# Disable specific instrumentations
OTEL_INSTRUMENTATION_KAFKA_ENABLED=false
OTEL_INSTRUMENTATION_GRPC_ENABLED=false
```
### Frequently Asked Questions
#### Does the OpenTelemetry Java Agent impact Micronaut performance?
The agent adds approximately 3-5ms of latency per request. With batch
processing and GZIP compression, the overhead is minimal for production
workloads. The agent uses bytecode manipulation at class load time, so
there's a small startup cost (~1-3 seconds) but negligible runtime impact.
#### Which Micronaut versions are supported?
The OpenTelemetry Java Agent supports Micronaut 3.x and 4.x with Java 17+.
Micronaut 4.x with Java 21+ is recommended. The agent instruments at the
Netty and JDBC level, which is stable across Micronaut versions.
#### Are Hibernate JPA queries traced automatically?
Yes. The agent intercepts all JDBC calls, which includes every query
Hibernate executes. Spans include the SQL statement (parameters obfuscated),
database name, and operation type. No per-query code changes needed.
#### Does the Java Agent propagate trace context across services?
Yes. The agent automatically injects W3C `traceparent` headers into outgoing
HTTP requests (via `java.net.http.HttpClient`, Apache HttpClient, OkHttp,
etc.) and extracts them from incoming requests. This enables distributed
tracing across services with zero code changes.
#### Can I use the Java Agent with GraalVM native images?
No. The Java Agent relies on JVM bytecode manipulation, which is not
available in GraalVM native images. For native images, use the
[OpenTelemetry SDK](https://opentelemetry.io/docs/languages/java/libraries/)
with manual instrumentation instead of the agent.
#### What is the difference between the Java Agent and the OpenTelemetry SDK?
The **Java Agent** provides zero-code instrumentation by attaching to the
JVM at startup. It instruments HTTP, JDBC, Netty, and 100+ libraries
automatically. The **SDK** requires you to add instrumentation code
manually. Use the agent for most applications; use the SDK when you need
fine-grained control or are building GraalVM native images.
#### Can I use the Java Agent alongside other APM tools?
Yes, the agent can coexist with tools like New Relic or Datadog during
migration periods. However, running multiple JVM agents simultaneously
increases startup time and memory usage. Plan your migration to remove the
legacy agent once OpenTelemetry is validated.
#### How do I instrument Micronaut Messaging consumers?
The Java Agent instruments Kafka and RabbitMQ consumers automatically. For
custom messaging, create manual spans:
```java
Span span = tracer.spanBuilder("process_message")
.setSpanKind(SpanKind.CONSUMER)
.setAttribute("messaging.system", "custom")
.setAttribute("messaging.destination", queueName)
.startSpan();
try (Scope scope = span.makeCurrent()) {
processMessage(message);
span.setStatus(StatusCode.OK);
} finally {
span.end();
}
```
#### How do I add tenant context in multi-tenant applications?
Use a Micronaut HTTP filter to add tenant attributes to every span:
```java
@Filter("/**")
public class TenantFilter implements HttpServerFilter {
@Override
public Publisher> doFilter(
HttpRequest> request, ServerFilterChain chain) {
String tenantId = request.getHeaders().get("X-Tenant-ID");
if (tenantId != null) {
Span.current().setAttribute("tenant.id", tenantId);
}
return chain.proceed(request);
}
}
```
#### Can I use Micronaut's built-in metrics with OpenTelemetry?
Micronaut has its own Micrometer-based metrics system. The Java Agent
provides JVM and HTTP metrics independently. Both can coexist, but for
consistency we recommend using the OpenTelemetry Meter API
(`GlobalOpenTelemetry.getMeter()`) for custom metrics when using the agent.
#### How do I correlate logs with traces in Micronaut?
The Java Agent automatically injects `trace_id` and `span_id` into the SLF4J
MDC. Use Logback with `logstash-encoder` to output JSON logs that include
these fields for trace-log correlation.
### What's Next?
Now that your Micronaut application is instrumented with OpenTelemetry,
explore these resources:
#### Advanced Topics
- **Custom Java Instrumentation** - Manual tracing, custom spans, and
advanced instrumentation patterns
- **PostgreSQL Monitoring Best Practices** - Database observability with
connection pooling metrics and query performance analysis
#### Scout Platform Features
- **Creating Alerts** - Set up alerts for error rates, latency thresholds,
and custom metrics
- **Dashboard Creation** - Build custom dashboards combining traces,
metrics, and business KPIs
#### Deployment and Operations
- **Docker Compose Setup** - Set up Scout Collector for local development
and testing
#### Related Guides
- [Spring Boot Instrumentation](./spring-boot.md) - Most widely used JVM web
framework
- [Quarkus Instrumentation](./quarkus.md) - Kubernetes-native JVM framework
- [Java Custom Instrumentation](../custom-instrumentation/java.md) - Manual
spans and advanced patterns
- [All framework guides](/instrument/apps/auto-instrumentation/) -
Auto-instrumentation overview for every language
### Complete Example
#### Project Structure
```plaintext
micronaut-postgres/
+-- app/
| +-- src/main/
| | +-- java/com/example/
| | | +-- Application.java
| | | +-- controller/
| | | | +-- ArticleController.java
| | | | +-- HealthController.java
| | | +-- model/
| | | | +-- Article.java
| | | +-- repository/
| | | | +-- ArticleRepository.java
| | | +-- service/
| | | +-- NotificationClient.java
| | | +-- TelemetryService.java
| | +-- resources/
| | +-- application.yml
| | +-- logback.xml
| | +-- db/migration/
| | +-- V1__create_articles.sql
| +-- build.gradle.kts
| +-- Dockerfile
+-- notify/
| +-- src/main/java/com/example/notify/
| | +-- Application.java
| | +-- controller/
| | +-- NotifyController.java
| +-- build.gradle.kts
| +-- Dockerfile
+-- config/
| +-- otel-config.yaml
+-- compose.yml
+-- .env.example
+-- scripts/
+-- test-api.sh
+-- verify-scout.sh
```
#### Running the Example
```bash
# Clone the examples repository
git clone https://github.com/base-14/examples.git
cd examples/java/micronaut-postgres
# Copy environment file
cp .env.example .env
# Start the stack
docker compose up --build
# Wait for services to be healthy (~30 seconds)
curl http://localhost:8080/api/health
# Run the full test suite
./scripts/test-api.sh
```
#### Testing the API
```bash
# Create an article
curl -s -X POST http://localhost:8080/api/articles \
-H "Content-Type: application/json" \
-d '{"title": "OpenTelemetry with Micronaut", "body": "Full observability"}' | jq .
# List articles
curl -s http://localhost:8080/api/articles | jq .
# Update an article
curl -s -X PUT http://localhost:8080/api/articles/1 \
-H "Content-Type: application/json" \
-d '{"title": "Updated Title"}' | jq .
# Delete an article
curl -s -X DELETE http://localhost:8080/api/articles/1
```
#### Expected Trace Output
After making requests, you'll see traces in Scout Dashboard with:
- **HTTP spans** for each controller action (GET, POST, PUT, DELETE)
- **JDBC spans** for every Hibernate query (SELECT, INSERT, UPDATE, DELETE)
- **HTTP client spans** for the notification service call
- **Correlated logs** with `trace_id` and `span_id` in every JSON log line
```plaintext
POST /api/articles (3ms)
+-- HikariCP getConnection (1ms)
+-- INSERT INTO articles ... (4ms)
+-- POST http://notify:8081/notify (15ms)
+-- [micronaut-notify] POST /notify (8ms)
```
Once telemetry is flowing, you can monitor Micronaut request performance in
Scout - track Hibernate query times, HTTP client latency, and error rates
from a unified dashboard.
### References
- [Official OpenTelemetry Java Documentation](https://opentelemetry.io/docs/languages/java/)
- [OpenTelemetry Java Agent](https://github.com/open-telemetry/opentelemetry-java-instrumentation)
- [Supported Libraries (Java Agent)](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md)
- [Micronaut Documentation](https://docs.micronaut.io/latest/guide/)
- [OpenTelemetry Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/)
---
## NestJS OpenTelemetry Instrumentation - TypeORM & BullMQ Tracing
## NestJS
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
### Overview
Implement OpenTelemetry instrumentation for NestJS applications to enable
comprehensive application performance monitoring (APM), distributed tracing, and
observability across your enterprise Node.js applications. This guide shows you
how to auto-instrument NestJS controllers, services, guards, interceptors,
TypeORM queries, BullMQ background jobs, and WebSocket gateways using the
OpenTelemetry Node.js SDK.
NestJS is an opinionated, structured framework that runs on top of
[Express](./express.md) or [Fastify](./fastify.md).
NestJS applications benefit from automatic instrumentation of the dependency
injection container, decorators, HTTP endpoints, TypeORM database queries,
Redis operations, BullMQ job processing, WebSocket connections, GraphQL
resolvers, and microservice communication. With OpenTelemetry, you can trace
requests through the entire dependency injection hierarchy, monitor async
context propagation, identify N+1 query problems, debug background job
failures, and track distributed transactions across microservices without
significant code changes.
Whether you're implementing observability for the first time, migrating from
New Relic or Datadog, troubleshooting performance issues in production, or
building enterprise-grade monitoring for microservices, this guide provides
production-ready configurations and best practices for NestJS OpenTelemetry
instrumentation with Base14 Scout.
:::tip TL;DR
Add a `TracingModule` that initializes the OpenTelemetry Node.js SDK and import
it first in your `AppModule` - controllers, TypeORM queries, and HTTP calls are
then traced automatically. Use `trace.getTracer()` directly in guards,
interceptors, and BullMQ processors for custom span coverage.
:::
### Who This Guide Is For
This documentation is designed for:
- **NestJS developers**: implementing observability and distributed tracing for
enterprise applications with dependency injection
- **Backend engineers**: deploying NestJS microservices with comprehensive
production monitoring requirements
- **DevOps teams**: standardizing observability across multiple NestJS services
in Kubernetes environments
- **Enterprise architects**: building observable systems with GraphQL,
WebSockets, message queues, and microservices
- **Full-stack developers**: debugging TypeORM queries, BullMQ jobs, and async
operations in production NestJS apps
### Prerequisites
Before starting, ensure you have:
- **Node.js 18.x or later** (20.x LTS recommended for production)
- **NestJS 10.x or later** installed (`@nestjs/core`, `@nestjs/common`)
- **TypeScript 4.9+** (5.x recommended)
- **Scout Collector** configured and accessible
- See
[Docker Compose Setup](../../collector-setup/docker-compose-example.md) for
local development
- See [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md)
for production
- Basic understanding of OpenTelemetry concepts (traces, spans, attributes)
- Familiarity with NestJS dependency injection and decorators
#### Compatibility Matrix
| Component | Minimum Version | Recommended Version |
| ----------------------------- | --------------- | ------------------- |
| Node.js | 18.0.0 | 20.x LTS |
| NestJS | 9.0.0 | 10.3.0+ |
| @opentelemetry/sdk-node | 0.40.0 | 0.200+ |
| @opentelemetry/auto-inst... | 0.40.0 | 0.76+ |
| TypeORM (if used) | 0.3.0 | 0.3.20+ |
| BullMQ (if used) | 4.0.0 | 5.x |
| @nestjs/websockets (optional) | 10.0.0 | 10.3.0+ |
| TypeScript | 4.9.0 | 5.3.0+ |
### Installation
Install the OpenTelemetry SDK and auto-instrumentation packages:
```bash showLineNumbers title="Install OpenTelemetry for NestJS"
npm install --save \
@opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/sdk-metrics \
@opentelemetry/resources \
@opentelemetry/semantic-conventions \
@opentelemetry/api
```
Install NestJS-specific packages if not already installed:
```bash showLineNumbers
npm install --save \
@nestjs/core \
@nestjs/common \
@nestjs/platform-express
```
### Configuration
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
Create a NestJS module for OpenTelemetry initialization:
```typescript showLineNumbers title="src/tracing/tracing.module.ts"
import { Module, OnModuleInit } from '@nestjs/common';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions';
@Module({})
export class TracingModule implements OnModuleInit {
private sdk: NodeSDK;
onModuleInit() {
this.sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]:
process.env.OTEL_SERVICE_NAME || 'nestjs-api',
[ATTR_SERVICE_VERSION]:
process.env.npm_package_version || '1.0.0',
'deployment.environment.name':
process.env.NODE_ENV || 'development',
'environment':
process.env.NODE_ENV || 'development',
}),
traceExporter: new OTLPTraceExporter({
url:
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ||
'http://localhost:4318/v1/traces',
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': {
enabled: false,
},
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
const ignorePaths = ['/health', '/metrics'];
return ignorePaths.some((path) => req.url?.includes(path));
},
},
}),
],
});
this.sdk.start();
}
async onModuleDestroy() {
await this.sdk.shutdown();
}
}
```
Import the module in your root AppModule:
```typescript showLineNumbers title="src/app.module.ts"
import { Module } from '@nestjs/common';
import { TracingModule } from './tracing/tracing.module';
import { UsersModule } from './users/users.module';
@Module({
imports: [
TracingModule, // Import FIRST for proper initialization
UsersModule,
// ... other modules
],
})
export class AppModule {}
```
```mdx-code-block
```
Create instrumentation file loaded before application bootstrap:
```typescript showLineNumbers title="instrumentation.ts"
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import {
ATTR_SERVICE_NAME,
} from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: 'nestjs-api',
'deployment.environment.name': process.env.NODE_ENV,
'environment': process.env.NODE_ENV,
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
process.on('SIGTERM', async () => {
await sdk.shutdown();
process.exit(0);
});
export default sdk;
```
Update `main.ts`:
```typescript showLineNumbers title="src/main.ts"
// Import instrumentation FIRST
import './instrumentation';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
```
```mdx-code-block
```
For containerized deployments:
```bash showLineNumbers title=".env"
# Service identification
OTEL_SERVICE_NAME=nestjs-api
OTEL_SERVICE_VERSION=1.0.0
NODE_ENV=production
# Exporter configuration
OTEL_TRACES_EXPORTER=otlp
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4318
# Resource attributes
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=development,environment=development,service.namespace=backend
# Performance tuning
OTEL_BSP_MAX_QUEUE_SIZE=2048
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
OTEL_BSP_SCHEDULE_DELAY=5000
```
Run with Node.js instrumentation:
```bash showLineNumbers
node --require ./instrumentation.js dist/main.js
```
```mdx-code-block
```
### Traces
Traces show the complete path of a request through your NestJS application,
from the incoming HTTP route, down through the NestJS handler, into database
queries and Redis calls, and back out as the response.
#### Automatic Trace Collection
Once the `TracingModule` is loaded, NestJS captures trace data for every request
with no per-handler code:
**Captured Information:**
- HTTP method, route, and status code for every controller endpoint
- Request duration and a timing breakdown across each span
- PostgreSQL queries, including the executed SQL (with `instrumentation-pg`);
TypeORM, Prisma, and Sequelize need their own instrumentation packages
- Redis commands, including those issued by BullMQ (with
`instrumentation-ioredis`)
- Outbound HTTP calls to other services (with `instrumentation-http`)
- Exceptions and stack traces recorded on the failing span
- Distributed context propagation across microservices (W3C Trace Context)
**Trace Hierarchy:**
```text
HTTP Request Span (root: GET /users/:id)
├── UsersController.findOne Span
│ ├── PostgreSQL Query Span (SELECT ... FROM users)
│ └── Redis GET Span (cache lookup)
└── Redis Span (BullMQ enqueue: welcome-email job)
```
#### Key Tracing Features
- **Automatic HTTP tracking**: every controller route is traced with no code
changes
- **NestJS-aware spans**: controller handlers, guards, interceptors, and pipes
are traced through `instrumentation-nestjs-core`; spans for your own service
methods are added manually
- **Error capturing**: thrown exceptions and filtered errors are recorded with
full stack traces
- **Context propagation**: distributed traces follow requests across HTTP, gRPC,
and message-queue boundaries
- **Async support**: context propagates across `async`/`await` and RxJS
observables
> View traces in your base14 Scout dashboard to follow request flows and find
> the slow span in a chain.
##### Reference
[Official Traces Documentation](https://opentelemetry.io/docs/concepts/signals/traces/)
### Metrics
Metrics aggregate runtime measurements over time, such as request rate, latency
distributions, and error counts. Where traces explain a single request, metrics
power dashboards and alerts across all of them.
#### Enable Metrics in the SDK
Add a metric reader to the `NodeSDK` so OpenTelemetry exports runtime and HTTP
metrics alongside traces:
```typescript showLineNumbers title="src/tracing/tracing.module.ts"
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
const otlpEndpoint =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318';
// inside new NodeSDK({ ... })
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: `${otlpEndpoint}/v1/metrics`,
}),
exportIntervalMillis: 10000,
}),
```
The HTTP instrumentation also emits server request-duration and request-count
metrics without further code.
#### Custom Business Metrics
The HTTP instrumentation already emits the standard server metrics, including
the `http.server.request.duration` histogram (its sample count gives you request
rate, latency percentiles, and error ratio per route), so there is no need to
hand-roll request latency. Reserve custom metrics for business events the
instrumentation cannot see, such as domain actions:
```typescript showLineNumbers title="src/articles/articles.service.ts"
import { Injectable } from '@nestjs/common';
import { metrics } from '@opentelemetry/api';
const meter = metrics.getMeter('nestjs-api');
const articlesCreated = meter.createCounter('articles.created', {
description: 'Articles created',
});
@Injectable()
export class ArticlesService {
async create(authorId: string /* ... */): Promise {
// ... persist the article ...
articlesCreated.add(1, { 'author.id': authorId });
}
}
```
> View metrics in your base14 Scout dashboard to chart request rate, latency
> percentiles, and error ratio per route from the automatic HTTP histogram,
> alongside your custom business counters.
##### Reference
[Official Metrics Documentation](https://opentelemetry.io/docs/concepts/signals/metrics/)
### Production Configuration
For production deployments with BatchSpanProcessor and resource attributes:
```typescript showLineNumbers title="src/tracing/tracing.production.ts"
import { Module, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
ATTR_SERVICE_INSTANCE_ID,
} from '@opentelemetry/semantic-conventions';
@Module({})
export class TracingModule implements OnModuleInit, OnModuleDestroy {
private sdk: NodeSDK;
onModuleInit() {
const traceExporter = new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: {
// Optional: Add authentication for Scout
// 'Authorization': `Bearer ${process.env.SCOUT_API_KEY}`,
},
timeoutMillis: 15000,
});
this.sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME,
[ATTR_SERVICE_VERSION]: process.env.npm_package_version,
'deployment.environment.name': process.env.NODE_ENV,
'environment': process.env.NODE_ENV,
[ATTR_SERVICE_INSTANCE_ID]: process.env.HOSTNAME || process.pid.toString(),
'service.namespace': process.env.SERVICE_NAMESPACE || 'default',
'container.id': process.env.CONTAINER_ID,
'k8s.pod.name': process.env.K8S_POD_NAME,
'k8s.namespace.name': process.env.K8S_NAMESPACE,
}),
spanProcessor: new BatchSpanProcessor(traceExporter, {
maxQueueSize: 2048,
maxExportBatchSize: 512,
scheduledDelayMillis: 5000,
exportTimeoutMillis: 30000,
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': {
enabled: false,
},
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
return ['/health', '/metrics', '/ready'].some((path) =>
req.url?.includes(path),
);
},
},
}),
],
});
this.sdk.start();
console.log('OpenTelemetry SDK initialized');
}
async onModuleDestroy() {
console.log('Shutting down OpenTelemetry SDK...');
await this.sdk.shutdown();
}
}
```
#### Docker Deployment
```dockerfile showLineNumbers title="Dockerfile"
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
ENV NODE_ENV=production
ENV OTEL_SERVICE_NAME=nestjs-api
EXPOSE 3000
CMD ["node", "dist/main.js"]
```
```yaml showLineNumbers title="docker-compose.yml"
version: '3.8'
services:
nestjs-api:
build: .
ports:
- '3000:3000'
environment:
- NODE_ENV=production
- OTEL_SERVICE_NAME=nestjs-api
- OTEL_SERVICE_VERSION=1.0.0
- OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4318
- DATABASE_URL=postgres://user:pass@postgres:5432/nestjs
- REDIS_URL=redis://redis:6379
depends_on:
- postgres
- redis
- scout-collector
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: nestjs
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
ports:
- '5432:5432'
redis:
image: redis:7-alpine
ports:
- '6379:6379'
scout-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ['--config=/etc/otel-collector-config.yaml']
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- '4318:4318'
```
### NestJS-Specific Instrumentation
#### Controllers and Routes
NestJS controllers are automatically instrumented via HTTP instrumentation:
```typescript showLineNumbers title="src/users/users.controller.ts"
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// Automatically traced as "GET /users"
@Get()
async findAll() {
return this.usersService.findAll();
}
// Automatically traced as "GET /users/:id"
@Get(':id')
async findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
// Automatically traced as "POST /users"
@Post()
async create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
}
```
Traces show:
- HTTP method and route pattern
- Response status codes
- Request/response headers (configurable)
- Timing for entire request lifecycle
#### Services with Dependency Injection
Services are traced when called from instrumented controllers:
```typescript showLineNumbers title="src/users/users.service.ts"
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository,
) {}
// Database queries automatically traced by TypeORM instrumentation
async findAll(): Promise {
return this.usersRepository.find();
}
async findOne(id: number): Promise {
return this.usersRepository.findOne({ where: { id } });
}
async create(createUserDto: CreateUserDto): Promise {
const user = this.usersRepository.create(createUserDto);
return this.usersRepository.save(user);
}
}
```
#### TypeORM Database Instrumentation
The SQL that TypeORM issues is traced at the driver level by `instrumentation-pg`
in the auto-instrumentations bundle (ORM-level spans require the separate
`@opentelemetry/instrumentation-typeorm` package):
```typescript showLineNumbers title="src/users/entities/user.entity.ts"
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
import { Order } from '../../orders/entities/order.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
@Column()
name: string;
@OneToMany(() => Order, (order) => order.user)
orders: Order[];
}
```
TypeORM Module configuration:
```typescript showLineNumbers title="src/app.module.ts"
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TracingModule } from './tracing/tracing.module';
@Module({
imports: [
TracingModule,
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 5432,
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: process.env.NODE_ENV !== 'production',
logging: process.env.NODE_ENV === 'development',
}),
// ... other modules
],
})
export class AppModule {}
```
Traces show:
- SQL queries with parameters
- Query execution time
- Connection pool metrics
- Transaction boundaries
#### Guards and Authentication
Guards are traced as part of the request lifecycle:
```typescript showLineNumbers title="src/auth/guards/jwt-auth.guard.ts"
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { trace } from '@opentelemetry/api';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
async canActivate(context: ExecutionContext): Promise {
const tracer = trace.getTracer('auth-guard');
return tracer.startActiveSpan('JwtAuthGuard.canActivate', async (span) => {
try {
const result = (await super.canActivate(context)) as boolean;
span.setAttribute('auth.success', result);
span.setStatus({ code: 1 }); // OK
return result;
} catch (error) {
span.recordException(error);
span.setStatus({ code: 2, message: error.message });
throw error;
} finally {
span.end();
}
});
}
}
```
#### Interceptors for Custom Tracing
Add custom attributes using interceptors:
```typescript showLineNumbers title="src/common/interceptors/tracing.interceptor.ts"
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { trace, context } from '@opentelemetry/api';
@Injectable()
export class TracingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable {
const request = context.switchToHttp().getRequest();
const activeSpan = trace.getActiveSpan();
if (activeSpan) {
// Add custom attributes
activeSpan.setAttribute('user.id', request.user?.id);
activeSpan.setAttribute('tenant.id', request.headers['x-tenant-id']);
activeSpan.setAttribute('request.path', request.path);
}
return next.handle().pipe(
tap(() => {
if (activeSpan) {
activeSpan.setAttribute('response.status', 'success');
}
}),
);
}
}
```
Apply globally:
```typescript showLineNumbers title="src/main.ts"
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { TracingInterceptor } from './common/interceptors/tracing.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new TracingInterceptor());
await app.listen(3000);
}
bootstrap();
```
#### BullMQ Background Jobs
Instrument BullMQ job processing:
```typescript showLineNumbers title="src/jobs/email.processor.ts"
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';
import { trace } from '@opentelemetry/api';
@Processor('email')
export class EmailProcessor {
@Process('send-welcome')
async handleWelcomeEmail(job: Job) {
const tracer = trace.getTracer('email-processor');
return tracer.startActiveSpan('EmailProcessor.sendWelcome', async (span) => {
try {
span.setAttributes({
'job.id': job.id.toString(),
'job.name': job.name,
'job.attempts': job.attemptsMade,
'user.email': job.data.email,
});
// Simulate email sending
await this.sendEmail(job.data.email, job.data.name);
span.setStatus({ code: 1 }); // OK
return { sent: true };
} catch (error) {
span.recordException(error);
span.setStatus({ code: 2, message: error.message });
throw error;
} finally {
span.end();
}
});
}
private async sendEmail(email: string, name: string) {
// Email sending logic
console.log(`Sending welcome email to ${email}`);
}
}
```
Queue module setup:
```typescript showLineNumbers title="src/jobs/jobs.module.ts"
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
import { EmailProcessor } from './email.processor';
@Module({
imports: [
BullModule.registerQueue({
name: 'email',
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT) || 6379,
},
}),
],
providers: [EmailProcessor],
})
export class JobsModule {}
```
#### WebSocket Gateway Instrumentation
Trace WebSocket connections and messages:
```typescript showLineNumbers title="src/chat/chat.gateway.ts"
import {
WebSocketGateway,
SubscribeMessage,
MessageBody,
ConnectedSocket,
OnGatewayConnection,
} from '@nestjs/websockets';
import { Socket } from 'socket.io';
import { trace } from '@opentelemetry/api';
@WebSocketGateway({ cors: true })
export class ChatGateway implements OnGatewayConnection {
private tracer = trace.getTracer('chat-gateway');
handleConnection(client: Socket) {
const span = this.tracer.startSpan('ChatGateway.handleConnection');
span.setAttributes({
'websocket.client.id': client.id,
'websocket.event': 'connection',
});
span.end();
}
@SubscribeMessage('message')
async handleMessage(
@MessageBody() data: { room: string; message: string },
@ConnectedSocket() client: Socket,
) {
return this.tracer.startActiveSpan('ChatGateway.handleMessage', async (span) => {
try {
span.setAttributes({
'websocket.client.id': client.id,
'websocket.room': data.room,
'message.length': data.message.length,
});
// Broadcast message to room
client.to(data.room).emit('message', {
sender: client.id,
message: data.message,
});
span.setStatus({ code: 1 });
return { status: 'sent' };
} catch (error) {
span.recordException(error);
span.setStatus({ code: 2, message: error.message });
throw error;
} finally {
span.end();
}
});
}
}
```
### Custom Instrumentation
For business logic and application-specific operations:
```typescript showLineNumbers title="src/orders/orders.service.ts"
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { Order } from './entities/order.entity';
import { CreateOrderDto } from './dto/create-order.dto';
@Injectable()
export class OrdersService {
private tracer = trace.getTracer('orders-service');
constructor(
@InjectRepository(Order)
private ordersRepository: Repository,
) {}
async createOrder(userId: number, createOrderDto: CreateOrderDto) {
return this.tracer.startActiveSpan('OrdersService.createOrder', async (span) => {
try {
span.setAttributes({
'user.id': userId,
'order.items.count': createOrderDto.items.length,
'order.total': this.calculateTotal(createOrderDto.items),
});
// Validate inventory
await this.tracer.startActiveSpan('validateInventory', async (validateSpan) => {
const available = await this.checkInventory(createOrderDto.items);
validateSpan.setAttribute('inventory.available', available);
if (!available) {
throw new Error('Insufficient inventory');
}
validateSpan.end();
});
// Create order
const order = await this.tracer.startActiveSpan('saveOrder', async (dbSpan) => {
const newOrder = this.ordersRepository.create({
userId,
items: createOrderDto.items,
total: this.calculateTotal(createOrderDto.items),
});
const saved = await this.ordersRepository.save(newOrder);
dbSpan.setAttribute('order.id', saved.id);
dbSpan.end();
return saved;
});
// Process payment
await this.tracer.startActiveSpan('processPayment', async (paymentSpan) => {
await this.processPayment(order.id, order.total);
paymentSpan.setAttribute('payment.status', 'completed');
paymentSpan.end();
});
span.setStatus({ code: SpanStatusCode.OK });
return order;
} catch (error) {
span.recordException(error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
throw error;
} finally {
span.end();
}
});
}
private calculateTotal(items: any[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
private async checkInventory(items: any[]): Promise {
// Inventory check logic
return true;
}
private async processPayment(orderId: number, amount: number): Promise {
// Payment processing logic
}
}
```
### Running Your Application
#### Development Mode
```bash showLineNumbers
# With console output for debugging
export OTEL_TRACES_EXPORTER=console
npm run start:dev
```
#### Production Mode
```bash showLineNumbers
export NODE_ENV=production
export OTEL_SERVICE_NAME=nestjs-api
export OTEL_EXPORTER_OTLP_ENDPOINT=https://scout.yourdomain.com/v1/traces
npm run start:prod
```
#### Using PM2
```javascript showLineNumbers title="ecosystem.config.js"
module.exports = {
apps: [
{
name: 'nestjs-api',
script: 'dist/main.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
OTEL_SERVICE_NAME: 'nestjs-api',
OTEL_EXPORTER_OTLP_ENDPOINT: 'http://scout-collector:4318',
},
},
],
};
```
Start with PM2:
```bash showLineNumbers
pm2 start ecosystem.config.js
pm2 logs nestjs-api
```
### Troubleshooting
#### Issue: No Traces from NestJS Controllers
**Solutions:**
1. Ensure TracingModule is imported first in AppModule:
```typescript
@Module({
imports: [
TracingModule, // MUST be first
TypeOrmModule.forRoot(/*...*/),
// other modules
],
})
export class AppModule {}
```
1. Verify HTTP instrumentation is enabled:
```typescript
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': {
enabled: true, // Explicitly enable
},
}),
];
```
#### Issue: TypeORM Queries Not Appearing in Traces
**Solutions:**
1. Install TypeORM instrumentation explicitly if needed:
```bash
npm install @opentelemetry/instrumentation-typeorm
```
1. Verify database connection is established after SDK initialization
2. Check TypeORM logging is enabled in development:
```typescript
TypeOrmModule.forRoot({
// ...
logging: true, // See queries in console
});
```
#### Issue: Missing Context in Async Operations
**Solutions:**
Use async/await instead of callbacks:
```typescript
// WRONG - loses context
setTimeout(() => {
const span = trace.getActiveSpan(); // undefined
}, 1000);
// CORRECT - preserves context
await new Promise((resolve) => setTimeout(resolve, 1000));
const span = trace.getActiveSpan(); // Works!
```
#### Issue: Guard/Interceptor Spans Not Showing
**Solutions:**
Guards and interceptors need manual span creation. Add custom tracing as shown
in the Guards and Interceptors sections above.
### Security Considerations
#### Sensitive Data Protection
Avoid capturing passwords, tokens, and PII in spans:
```typescript showLineNumbers
// BAD - Exposes sensitive data
span.setAttributes({
'user.password': password,
'user.email': email,
'credit_card': cardNumber,
});
// GOOD - Use safe identifiers
span.setAttributes({
'user.id': userId,
'user.type': 'customer',
'payment.method': 'credit_card',
});
```
#### HTTP Header Filtering
Configure header filtering to exclude authentication tokens:
```typescript showLineNumbers
'@opentelemetry/instrumentation-http': {
headersToSpanAttributes: {
requestHeaders: ['content-type', 'user-agent'],
responseHeaders: ['content-type'],
},
},
```
#### Database Query Sanitization
TypeORM automatically sanitizes parameters, but verify in traces:
```typescript
// Parameters are automatically sanitized
const user = await this.usersRepository.findOne({
where: { email: userEmail }, // Safe - uses parameterized query
});
```
#### Environment Variable Security
Never log sensitive environment variables:
```typescript showLineNumbers
// BAD
console.log('DB_PASSWORD:', process.env.DB_PASSWORD);
// GOOD - Use configuration service
@Injectable()
export class ConfigService {
get(key: string): string {
const value = process.env[key];
if (!value && this.isProduction()) {
throw new Error(`Missing required config: ${key}`);
}
return value;
}
}
```
### Performance Considerations
OpenTelemetry adds minimal overhead to NestJS applications:
**Expected Impact:**
- **Latency**: +0.5-2ms per request with auto-instrumentation
- **CPU**: +2-5% in production with BatchSpanProcessor
- **Memory**: +15-35MB for trace buffers and SDK
- **Throughput**: <1% reduction in requests/second
#### Optimization Best Practices
##### 1. Use BatchSpanProcessor in Production
```typescript showLineNumbers
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
spanProcessor: new BatchSpanProcessor(traceExporter, {
maxQueueSize: 2048,
maxExportBatchSize: 512,
scheduledDelayMillis: 5000,
});
```
##### 2. Skip Health Check Endpoints
```typescript showLineNumbers
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
return ['/health', '/metrics', '/ready'].some((path) =>
req.url?.includes(path),
);
},
},
```
##### 3. Disable Filesystem Tracing
```typescript showLineNumbers
'@opentelemetry/instrumentation-fs': {
enabled: false,
},
```
##### 4. Optimize TypeORM Queries
Use query builder for complex queries to reduce overhead:
```typescript showLineNumbers
// Efficient - single query with joins
const users = await this.usersRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.orders', 'order')
.where('user.active = :active', { active: true })
.getMany();
// Inefficient - N+1 queries
const users = await this.usersRepository.find({ where: { active: true } });
for (const user of users) {
user.orders = await this.ordersRepository.find({ where: { userId: user.id } });
}
```
### FAQ
#### Does OpenTelemetry work with NestJS dependency injection?
Yes, OpenTelemetry fully supports NestJS DI. TracingModule can be imported and
services are automatically traced when called from instrumented controllers.
#### What is the performance impact of OpenTelemetry on NestJS applications?
With BatchSpanProcessor, expect +0.5-2ms latency per request, +2-5% CPU, and
+15-35MB memory. Minimal impact for most production workloads.
#### Can I trace TypeORM, Prisma, and Sequelize?
The SQL these ORMs issue is traced at the driver level by `instrumentation-pg`
(or the equivalent driver package) in the auto-instrumentations bundle. ORM-level
spans for TypeORM, Prisma, and Sequelize need their own instrumentation packages,
which are not part of `auto-instrumentations-node`.
#### How do I trace BullMQ background jobs in NestJS?
BullMQ has no dedicated auto-instrumentation package. The Redis commands it
issues are traced by `instrumentation-ioredis`, so add a manual span in the
processor with `trace.getTracer()` and propagate trace context through the job
data to link the job to the request that enqueued it. See the
[BullMQ guide](./bullmq.md).
#### Does OpenTelemetry work with NestJS WebSocket gateways?
WebSocket connections and messages require manual instrumentation. Use
`trace.getTracer()` in gateway methods as shown in the WebSocket section.
#### How do I trace GraphQL resolvers?
GraphQL queries are traced via HTTP instrumentation. Add custom spans in
resolvers for field-level tracing using decorators or interceptors.
#### Can I use it with NestJS microservices?
Yes, OpenTelemetry traces distributed microservices automatically. Context
propagates across HTTP, gRPC, and message queue boundaries.
#### How do I handle multi-tenant applications?
Add tenant ID as span attribute in guards or interceptors:
`span.setAttribute('tenant.id', tenantId)` and filter in Scout Dashboard.
#### What's the difference between traces and metrics?
Traces show request flow and timing through your NestJS app (spans). Metrics
aggregate performance data (counters, histograms). Both are supported.
#### Can I trace custom decorators and metadata?
Yes, use interceptors or method decorators to add custom spans. Access metadata
using `Reflector` and add attributes to active spans.
#### Can I trace TypeORM and Prisma queries in NestJS with OpenTelemetry?
The SQL these ORMs issue is traced at the driver level by `instrumentation-
pg` (or the matching driver package) in the auto-instrumentations bundle.
ORM-level spans for `TypeORM`, Prisma, and `Sequelize` need their own
instrumentation packages, which are not part of auto-instrumentations-node.
### What's Next?
#### Scout Platform Features
- [Creating Alerts](../../../guides/creating-alerts-with-logx.md) - Set up
alerts for API latency, errors, and database queries
- [Dashboard Creation](../../../guides/create-your-first-dashboard.md) - Build
custom dashboards for NestJS metrics
#### Deployment and Operations
- [Docker Compose Setup](../../collector-setup/docker-compose-example.md) -
Local development environment with PostgreSQL and Redis
- [Kubernetes Helm Setup](../../collector-setup/kubernetes-helm-setup.md) -
Production deployment on Kubernetes
### Complete Example
Here's a complete working NestJS application with OpenTelemetry instrumentation:
#### package.json
```json showLineNumbers title="package.json"
{
"name": "nestjs-otel-example",
"version": "1.0.0",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/typeorm": "^10.0.1",
"@nestjs/bull": "^10.0.1",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
"@opentelemetry/sdk-metrics": "^2.7.1",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/semantic-conventions": "^1.41.1",
"@opentelemetry/api": "^1.9.0",
"typeorm": "^0.3.20",
"pg": "^8.11.0",
"bull": "^4.12.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
}
}
```
#### Environment Variables
```bash showLineNumbers title=".env.production"
NODE_ENV=production
OTEL_SERVICE_NAME=nestjs-api
OTEL_SERVICE_VERSION=1.0.0
OTEL_EXPORTER_OTLP_ENDPOINT=http://scout-collector:4318
OTEL_SEMCONV_STABILITY_OPT_IN=http,database
DATABASE_URL=postgres://user:pass@postgres:5432/nestjs
REDIS_URL=redis://redis:6379
```
`OTEL_SEMCONV_STABILITY_OPT_IN=http,database` opts the instrumentation into the
stable HTTP and database semantic conventions (for example
`http.request.method`, `http.response.status_code`, and `db.query.text`).
Without it, the instrumentation keeps emitting the older experimental attribute
names (`http.method`, `http.status_code`, `db.statement`). Use `http/dup` and
`database/dup` instead to emit both old and new during a migration.
#### GitHub Repository
Complete working example:
[GitHub: base-14/examples/nodejs/nestjs-postgres](https://github.com/base-14/examples/tree/main/nodejs/nestjs-postgres)
### References
- [Official OpenTelemetry Node.js Documentation](https://opentelemetry.io/docs/languages/js/)
- [NestJS Documentation](https://docs.nestjs.com/)
- [OpenTelemetry Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/)
- [TypeORM Documentation](https://typeorm.io/)
### Related Guides
- [Express Instrumentation](./express.md) - The HTTP layer NestJS runs on by
default
- [Next.js Instrumentation](./nextjs-scout.md) - Full-stack React framework on Node.js
- [Fastify Instrumentation](./fastify.md) - Alternate NestJS HTTP adapter
- [Hono Instrumentation](./hono.md) - Lightweight Node.js web framework
- [BullMQ Instrumentation](./bullmq.md) - Trace the background jobs NestJS
enqueues
---
## Next.js Full-Stack OpenTelemetry - Browser and Server
```mdx-code-block
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
```
## Next.js (Full-Stack)
:::note Running this in production
Storing and querying this data at production volume is what base14 Scout does.
[Check out Scout APM](https://base14.io/scout/apm).
:::
:::info Server side only?
This guide covers browser and server together, exporting to a collector. If you
only need the server side, or you have nowhere to run a collector, start with
[Next.js](./nextjs-scout.md) — direct OTLP to Scout with OIDC client
credentials — and add the browser tier from here on top.
:::
### Introduction
Instrument a Next.js App Router application with OpenTelemetry on both sides of
the network boundary: the Node.js server that renders pages and serves API
routes, and the browser that hydrates and runs them. This guide wires the Node
SDK into the `instrumentation.ts` register hook, mounts the web SDK from a
client component in the root layout, and links the two through the W3C
`traceparent` header so a click in the browser and the API route it calls land
in one trace.
It covers all three signals. **Traces** cover document load, user interactions,
browser `fetch`/XHR, server-side rendering, and API route execution.
**Metrics** cover HTTP and Node.js runtime instruments exported on a periodic
reader. **Logs** cover application records emitted through the OTel logger plus
a console bridge that captures the SSR error output Next.js prints to stdout
instead of surfacing through a span. Three kinds of browser error are handled
separately: uncaught exceptions, unhandled promise rejections, and React error
boundary crashes, which a `window.onerror` listener does not see.
Browser telemetry reaches the collector through a catch-all API route at
`/api/otel` rather than being exported directly. The browser posts same-origin,
so there is no preflight and no `cors` block on the OTLP HTTP receiver, and the
collector does not have to be reachable from a user's network. The alternative
is direct export with a CORS allow-list on the collector; the
[Angular guide](./angular.md) covers the same three signals that way.
:::tip TL;DR
Server side, create `instrumentation.ts` at the project root and dynamically
import your `NodeSDK` setup only when `process.env.NEXT_RUNTIME === 'nodejs'`,
so the SDK never loads in the Edge runtime. Browser side, call the web SDK
bootstrap from a `'use client'` component mounted in the root layout, and point
its OTLP exporter at a catch-all `/api/otel/[...signal]` route that forwards to
the collector - this makes browser telemetry same-origin and removes collector
CORS entirely. Set `propagateTraceHeaderCorsUrls` so `traceparent` links browser
spans to API route spans, and wrap `console.*` after `sdk.start()` so Next.js
internal SSR errors reach your logs pipeline.
:::
### Who This Guide Is For
This documentation is designed for:
- **Next.js developers**: adding tracing, metrics, and logs to an App Router
app without bolting on a proprietary RUM agent.
- **Full-stack engineers**: connecting a browser interaction to the server
render and API route behind it through a single trace id.
- **Front-end platform teams**: standardizing browser observability across
several Next.js apps, including error boundaries and Core Web Vitals.
- **SRE and DevOps**: shipping an instrumented Next.js container that exports to
a collector without exposing that collector to the public internet.
- **Teams migrating off commercial APM**: replacing a vendor agent with
vendor-neutral OTLP while keeping browser-to-server correlation.
### Overview
#### Prerequisites
Before starting, ensure you have:
- **Node.js 22 or later** - the example builds and runs on `node:22-alpine`.
- **Next.js 15 or later** using the App Router. The `instrumentation.ts`
register hook has been stable since 15; the example runs **Next.js 16.3**.
- **A Scout Collector** reachable from the Next.js server over OTLP/HTTP, with
traces, metrics, and logs pipelines. See
[Docker Compose collector setup](../../collector-setup/docker-compose-example.md).
- **Docker and Docker Compose** to run the full stack locally.
#### Compatibility Matrix
| Component | Version | Notes |
| --- | --- | --- |
| Next.js | 16.3.0 | App Router; register hook stable since 15 |
| React | 19.2.8 | Error boundaries via `error.tsx` / `global-error.tsx` |
| Node.js | 22 | Server runtime; Edge runtime is excluded by design |
| TypeScript | 6.0.3 | |
| `@opentelemetry/api` | 1.9.1 | |
| `@opentelemetry/sdk-node` | 0.221.0 | Server SDK, loaded from the register hook |
| `@opentelemetry/auto-instrumentations-node` | 0.79.0 | HTTP, fetch, and runtime |
| `@opentelemetry/sdk-trace-web` | 2.10.0 | Browser tracer provider |
| `@opentelemetry/auto-instrumentations-web` | 0.66.0 | Document load, fetch, XHR, interactions |
| `@opentelemetry/semantic-conventions` | 1.43.0 | `ATTR_*` constants |
| `web-vitals` | 5.3.0 | CLS, LCP, TTFB, INP |
#### Instrumented Components
| Surface | What is captured | Automatic |
| --- | --- | --- |
| Incoming HTTP requests | Method, route, status, duration | Yes |
| Server-side rendering | Render spans per route | Yes |
| API route execution | One span per handler invocation | Yes |
| Server-side `fetch` | Outbound calls made during SSR | Yes |
| Node.js runtime metrics | Event loop, memory, GC | Yes |
| Document load | Navigation and resource timing | Yes |
| Browser `fetch` / XHR | One span per client request, with `traceparent` | Yes |
| User interactions | `click` and `submit` spans | Yes |
| Application logs | INFO / WARN / ERROR records with attributes | No |
| Next.js SSR error output | stdout error text as ERROR log records | No |
| Browser JS errors | Uncaught exceptions and rejections as spans | No |
| React error boundaries | `error.tsx` and `global-error.tsx` catches | No |
| Core Web Vitals | CLS, LCP, TTFB, INP as spans | No |
The rows marked **No** are wired explicitly in the example, and each has its own
section below. The full runnable project is at
[base-14/examples/nodejs/nextjs-fullstack-otel](https://github.com/base-14/examples/tree/main/nodejs/nextjs-fullstack-otel).
:::note
This example has no database. The `/api/products` route serves an in-memory
array behind a small artificial delay. For database span capture in a Next.js
app, see the [Next.js server-side guide](./nextjs.md), whose example uses
MongoDB.
:::
### Installation
The server and browser SDKs are separate dependency sets, but they install
together because both bundles are built from one `package.json`.
```mdx-code-block