Skip to content

feat: add OpenTelemetry tracing for agent execution#89

Merged
pavelanni merged 4 commits into
mainfrom
feature/otel-tracing
Jun 4, 2026
Merged

feat: add OpenTelemetry tracing for agent execution#89
pavelanni merged 4 commits into
mainfrom
feature/otel-tracing

Conversation

@pavelanni

@pavelanni pavelanni commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add internal/telemetry/ package with OTel SDK initialization, span attribute constants, and conversation content capture helpers (10KB truncation)
  • Instrument the agentic tool loop (pkg/tools/loop.go) with parent/child spans for loop, iterations, and tool execution
  • Add HTTP middleware for root request spans and W3C trace context propagation across A2A delegation
  • Instrument both Anthropic and OpenAI providers with llm.api.call spans capturing token usage and latency
  • Extract inbound W3C traceparent from A2A ServiceParams for distributed multi-agent tracing
  • Add --otel-stdout flag for JSON span export to stdout (for kubectl logs / log pipelines)

Trace hierarchy

HTTP POST /a2a
  └── a2a.execute
      └── agent.loop
          └── agent.loop.iteration.1
              ├── llm.api.call (anthropic)
              ├── tool.execute (read_file)
              └── tool.execute (web_fetch)

New flags

Flag Description
--otel-enabled Enable OpenTelemetry tracing (existing, now functional)
--otel-collector-endpoint OTLP gRPC endpoint (existing, now functional)
--otel-stdout Export spans as JSON to stdout (new)

Zero overhead when disabled — OTel no-op provider, no if enabled checks in instrumentation code.

Closes #34

Test plan

  • 7 new unit tests (telemetry init, truncation, span events, tracing verification)
  • Full test suite passes (17 packages, 0 regressions)
  • golangci-lint clean on all modified packages
  • Manual verification with --otel-stdout — trace hierarchy confirmed locally
  • Test with Jaeger (docker run jaegertracing/all-in-one) and --otel-collector-endpoint
  • Test with MLflow OTel trace ingestion

Summary by CodeRabbit

  • New Features

    • Added OpenTelemetry distributed tracing across HTTP, agent loops, tool calls, and LLM/provider interactions.
    • New CLI flag --otel-stdout to optionally emit JSON traces to stdout.
    • Logging now supports auto-detected format with LOG_FORMAT override.
  • Configuration

    • Bumped OpenTelemetry-related dependencies (v1.44.0).
    • Component identifier changed from "ZT-AGENT" to "DOCSCLAW".
  • Tests

    • Added comprehensive tracing unit tests and integration checks.

Add distributed tracing across the full agent request lifecycle:
LLM API calls, agentic tool loop iterations, tool execution,
HTTP request handling, and A2A delegation. Conversation content
(messages, tool args/results) is captured as span events with
10KB truncation. Token usage is recorded as span attributes.

Tracing is zero-overhead when disabled (OTel no-op provider).
Three export modes: OTLP gRPC (Jaeger/Tempo), stdout JSON
(kubectl logs), or both simultaneously.

New flags: --otel-enabled, --otel-collector-endpoint, --otel-stdout

Closes #34

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Enterprise

Run ID: b0e0279d-9f67-4aad-bb50-df543bc1c74f

📥 Commits

Reviewing files that changed from the base of the PR and between df064ff and 90ceca7.

📒 Files selected for processing (7)
  • internal/anthropic/anthropic.go
  • internal/cmd/serve.go
  • internal/openai/openai.go
  • internal/telemetry/content.go
  • internal/telemetry/telemetry.go
  • pkg/tools/loop.go
  • pkg/tools/loop_test.go

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting


📝 Walkthrough

Walkthrough

This PR integrates OpenTelemetry distributed tracing across the service: adds a telemetry package, HTTP middleware, instruments LLM providers, the A2A bridge, and the agent tool loop; wires telemetry init/shutdown and config/CLI; adds tests; and updates go.mod for OTel v1.44.0.

Changes

OpenTelemetry tracing implementation

Layer / File(s) Summary
Telemetry core and tests
internal/telemetry/attributes.go, internal/telemetry/content.go, internal/telemetry/http.go, internal/telemetry/telemetry.go, internal/telemetry/telemetry_test.go
Core telemetry package defines attribute keys and event helpers with 10KB truncation; HTTPMiddleware extracts W3C trace context and creates per-request spans; Init configures global tracer provider with optional OTLP gRPC and stdout exporters; unit tests validate Init, truncation, and event emission.
HTTP middleware & response writer
internal/telemetry/http.go
Middleware starts HTTP server spans, extracts incoming trace context, wraps response writer to capture status while preserving streaming/flusher behavior.
Configuration, logging, and server integration
internal/config/config.go, internal/logger/logger.go, internal/cmd/serve.go
Adds otel.stdout_exporter config and --otel-stdout flag; runServe initializes telemetry with cfg and version, conditionally wraps HTTP handler with middleware, and calls telemetry shutdown on graceful stop; logger ComponentAgent renamed and New enhanced to select log format via LOG_FORMAT/terminal detection.
LLM provider instrumentation
internal/anthropic/anthropic.go, internal/openai/openai.go
Adds "llm.api.call" spans for Complete and StreamWithTools, attaches provider/model attributes, records token-usage and stop-reason attributes, emits message events, and marks spans as error on failures.
A2A bridge trace context propagation
internal/bridge/client.go, internal/bridge/executor.go
Client injects W3C trace context into ServiceParams; executor extracts traceparent/tracestate/baggage into ctx and starts "a2a.execute" spans with task/session attributes.
Tool loop and execution instrumentation
pkg/tools/loop.go, pkg/tools/loop_test.go
RunToolLoop and iterations are instrumented (agent.loop, agent.loop.iteration.N); LLM inputs/responses are recorded as events; executeTool uses tool.execute spans recording args/results and error/denial attributes; tests assert expected spans/attributes/events.
Dependency management
go.mod
Adds direct OpenTelemetry SDK and exporter packages at v1.44.0 and updates indirect dependencies (OTLP proto, auto SDK, grpc/protobuf and golang.org/x/* bumps).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • redhat-et/docsclaw#49: Overlaps modifications in pkg/tools/loop.go (tool result handling/truncation) that intersect with loop/tool instrumentation.
  • redhat-et/docsclaw#18: Related to A2A ServiceParams request context handling that this PR uses for trace propagation.
  • redhat-et/docsclaw#57: Modifies streaming provider paths; this PR instruments the same StreamWithTools/streaming code paths.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main change: adding OpenTelemetry tracing for agent execution.
Linked Issues check ✅ Passed The PR implements comprehensive OTel instrumentation across all proposed components: agent loop spans with iteration tracking, LLM API call spans for Anthropic/OpenAI, tool execution spans, HTTP request tracing, and A2A trace context propagation.
Out of Scope Changes check ✅ Passed All code changes directly support OTel tracing implementation. The logger component rename (ZT-AGENT → DOCSCLAW) is a minor supporting change; go.mod updates reflect required OTel dependencies.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/otel-tracing

Comment @coderabbitai help to get the list of available commands and usage tips.

pavelanni added 2 commits June 3, 2026 20:35
Rename the logger component from the legacy "ZT-AGENT" (Zero Trust
demo) to "DOCSCLAW". Auto-detect output format based on TTY: color
text for local dev terminals, structured JSON for K8s pods and pipes.

Override with LOG_FORMAT=json|text. SPIFFE_DEMO_LOG_FORMAT kept as
legacy alias. NO_COLOR still respected in text mode.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
- Fix statusWriter to implement http.Flusher (and Unwrap) so SSE
  streaming continues to work through the OTel HTTP middleware
- Remove codes.Error from denied tool calls — policy denial is
  expected behavior, not an operational error
- Set error status on loopSpan for max iterations and context
  cancellation so these are distinguishable from success in traces
- Add response content and token usage as span events/attributes
  on both providers' Complete methods (closes observability gap)
- Extract W3C baggage header alongside traceparent in A2A executor

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
@pavelanni pavelanni marked this pull request as ready for review June 4, 2026 00:43

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/cmd/serve.go (1)

610-617: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Give telemetry shutdown its own timeout budget.

server.Shutdown and otelShutdown share the same 5s context. If the server drain uses most of that window, tracer shutdown gets an expired context and drops the final batch of spans.

Suggested fix
 		log.Info("Shutting down docsclaw...")
-		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
-		defer cancel()
-		if err := server.Shutdown(shutdownCtx); err != nil {
+		serverShutdownCtx, serverCancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer serverCancel()
+		if err := server.Shutdown(serverShutdownCtx); err != nil {
 			log.Error("Shutdown error", "error", err)
 		}
-		if err := otelShutdown(shutdownCtx); err != nil {
+
+		otelShutdownCtx, otelCancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer otelCancel()
+		if err := otelShutdown(otelShutdownCtx); err != nil {
 			log.Error("OTel shutdown error", "error", err)
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cmd/serve.go` around lines 610 - 617, server.Shutdown and
otelShutdown are sharing the same shutdownCtx so if server.Shutdown consumes the
5s budget tracer shutdown may receive an expired context and drop spans; change
the sequence to use one dedicated timeout context for the HTTP server (e.g.,
shutdownCtx from context.WithTimeout(...)) for server.Shutdown, then create a
fresh context.WithTimeout (and defer its cancel) solely for otelShutdown and
call otelShutdown with that new context so telemetry has its own timeout budget.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/anthropic/anthropic.go`:
- Around line 63-68: Both Complete and StreamWithTools create an OpenTelemetry
span but do not consistently mark failures; update these methods (methods:
(*AnthropicProvider).Complete and (*AnthropicProvider).StreamWithTools) so that
every error return records the error and sets the span status before span.End():
call span.RecordError(err) and span.SetStatus(codes.Error, err.Error()) for any
non-nil err. Implement this by adding a deferred error-check wrapper immediately
after span creation that inspects a named return error (or a local err variable)
and, if non-nil, calls span.RecordError and span.SetStatus, ensuring the span is
marked failed for all early returns (also apply the same pattern to the stream
error paths and JSON/tooling error returns).

In `@internal/cmd/serve.go`:
- Around line 335-341: When cfg.OTel.Enabled is true but no exporter is
configured (StdoutExporter, CollectorEndpoint, or OTEL_EXPORTER_OTLP_ENDPOINT),
telemetry.Init will create a no-op tracer and silently drop spans; change the
startup path around the telemetry.Init call to detect this misconfiguration and
fail fast: before calling telemetry.Init (or by inspecting its return/status),
check cfg.OTel.Enabled and whether cfg.OTel.StdoutExporter or
cfg.OTel.CollectorEndpoint or the OTEL_EXPORTER_OTLP_ENDPOINT env var is set,
and if none are present log a clear error and exit; reference the telemetry.Init
call and cfg.OTel.Enabled/StdoutExporter/CollectorEndpoint symbols to locate
where to add this validation.

In `@internal/telemetry/content.go`:
- Around line 53-63: truncate currently slices to maxBytes then appends the
suffix, causing outputs to exceed the intended maxEventBytes; update truncate(s
string, maxBytes int) to reserve space for the suffix ("...[truncated]") by
computing suffix := "...[truncated]" and if maxBytes <= len(suffix) return
suffix[:maxBytes]; otherwise slice to maxBytes-len(suffix), walk back to a UTF-8
boundary (as before), trim trailing NULs and then append the suffix so the total
byte length never exceeds maxBytes; this change in truncate will automatically
fix AddMessageEvent, AddToolArgsEvent and AddToolResultEvent which call it.

In `@internal/telemetry/telemetry.go`:
- Around line 64-68: The current telemetry setup in
internal/telemetry/telemetry.go appends otlptracegrpc.WithInsecure() to grpcOpts
whenever OTEL_EXPORTER_OTLP_INSECURE is unset; change this to require an
explicit true value to enable plaintext (i.e., only append
otlptracegrpc.WithInsecure() when os.Getenv("OTEL_EXPORTER_OTLP_INSECURE") ==
"true"), and otherwise initialize secure TLS transport for the OTLP gRPC
exporter (remove the default insecure branch and ensure grpcOpts uses TLS
credentials by default); update the code paths that build grpcOpts (the grpcOpts
variable and the creation of the OTLP gRPC exporter) so secure/TLS is the
default, and document/validate the env check accordingly.
- Around line 56-76: The current condition prevents creating the OTLP exporter
when no endpoint env/flag is set, causing spans to be dropped; update the
creation logic so otlptracegrpc.New(...) is called when traces should be
exported even if no endpoint was provided: change the if that guards exporter
creation to true when cfg.Enabled && !cfg.StdoutExporter OR when
cfg.CollectorEndpoint != "" OR env OTEL_EXPORTER_OTLP_ENDPOINT != "" OR env
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT != ""; keep the existing grpcOpts handling
(append WithEndpoint when cfg.CollectorEndpoint set, keep WithInsecure default
behavior), call otlptracegrpc.New(ctx, grpcOpts...) in that branch, and preserve
the error handling and slog.Info call (adjust log to include which env/flag was
used if you like).

In `@pkg/tools/loop_test.go`:
- Around line 437-443: The cleanup in TestRunToolLoopTracing unconditionally
sets the global tracer provider to noop.NewTracerProvider(), which can interfere
with other tests; before calling otel.SetTracerProvider(tp) capture the current
provider (e.g., prev := otel.GetTracerProvider()), and in the t.Cleanup restore
it via otel.SetTracerProvider(prev) while still shutting down tp (tp.Shutdown).
Update the t.Cleanup closure to restore the saved previous provider instead of
always setting noop.NewTracerProvider(), referencing the tp,
otel.SetTracerProvider, otel.GetTracerProvider, and t.Cleanup symbols.

In `@pkg/tools/loop.go`:
- Around line 63-67: When provider.CompleteWithTools returns an error in
agent.loop (the call to provider.CompleteWithTools), set the higher-level
loopSpan status to codes.Error before returning (in addition to
iterSpan.SetStatus) so loopSpan reflects the failure; similarly, in executeTool
where the "tool.execute" span marks a denied tool call via attributes, call
span.SetStatus(codes.Error, "<reason>") (or include err.Error() when available)
to mark the span as an error; update the code paths around
provider.CompleteWithTools, iterSpan, loopSpan, and executeTool/"tool.execute"
to call span.SetStatus(codes.Error, ...) with a concise error message before
ending/returning.
- Around line 160-167: The denied-tool path in executeTool sets
telemetry.AttrToolDenied and AttrToolDenyReason but does not mark the span as
failed; update the hook-denied branch (the block that calls span.SetAttributes
and slog.Warn for tc.Name) to also call span.SetStatus(codes.Error, reason)
(using the same status code pattern as other failure paths) so the span is
recorded as an error when a tool call is denied; ensure the codes symbol used
matches existing imports where other failures set span status.

---

Outside diff comments:
In `@internal/cmd/serve.go`:
- Around line 610-617: server.Shutdown and otelShutdown are sharing the same
shutdownCtx so if server.Shutdown consumes the 5s budget tracer shutdown may
receive an expired context and drop spans; change the sequence to use one
dedicated timeout context for the HTTP server (e.g., shutdownCtx from
context.WithTimeout(...)) for server.Shutdown, then create a fresh
context.WithTimeout (and defer its cancel) solely for otelShutdown and call
otelShutdown with that new context so telemetry has its own timeout budget.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Enterprise

Run ID: f13001e5-f5d0-47fd-83f6-19d19914436a

📥 Commits

Reviewing files that changed from the base of the PR and between 4dbefe3 and df064ff.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (15)
  • go.mod
  • internal/anthropic/anthropic.go
  • internal/bridge/client.go
  • internal/bridge/executor.go
  • internal/cmd/serve.go
  • internal/config/config.go
  • internal/logger/logger.go
  • internal/openai/openai.go
  • internal/telemetry/attributes.go
  • internal/telemetry/content.go
  • internal/telemetry/http.go
  • internal/telemetry/telemetry.go
  • internal/telemetry/telemetry_test.go
  • pkg/tools/loop.go
  • pkg/tools/loop_test.go

Comment on lines +63 to +68
ctx, span := otel.Tracer(telemetry.TracerName).Start(ctx, "llm.api.call",
trace.WithAttributes(
telemetry.AttrLLMProvider.String(p.ProviderName()),
telemetry.AttrLLMModel.String(p.model),
))
defer span.End()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In OpenTelemetry Go v1.44.0, what is the recommended way to mark a span as failed on error returns? Please verify whether spans should call both RecordError(err) and SetStatus(codes.Error, ...) before End().

💡 Result:

In OpenTelemetry Go v1.44.0, the recommended way to mark a span as failed when an operation returns an error is to explicitly set the span status to Error and record the error [1][2]. To achieve this, you should perform both of the following steps before ending the span [1][3]: 1. Use span.SetStatus(codes.Error, description) to set the span's status code to Error [1][4]. This identifies the span as a failed operation in telemetry backends [1][3]. 2. Use span.RecordError(err) to record the error as a span event [1][4]. This attaches the error details (such as the error type and message) to the span [5][3]. It is important to call both methods because they serve different purposes and operate independently [5][4][3]. The RecordError function does not automatically set the span's status to Error [1][5][3]. Decoupling these actions provides flexibility, allowing you to record errors (e.g., transient errors or retried operations) without necessarily marking the entire span as failed [6][3][7]. Note that while these are the standard tools for custom instrumentation, recent updates to OpenTelemetry semantic conventions have shifted focus for automatic instrumentation toward using span attributes (such as error.type) instead of recording errors as span events in certain contexts [8][9][10]. However, for manual error handling where you intend to surface a failure, the combination of SetStatus and RecordError remains the correct and recommended approach [1][2][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and print the relevant portions of the file with line numbers
FILE="internal/anthropic/anthropic.go"

if [ ! -f "$FILE" ]; then
  echo "Missing $FILE"
  exit 1
fi

echo "---- $FILE (lines 1-220) ----"
sed -n '1,220p' "$FILE" | nl -ba

echo "---- Extract AnthropicProvider methods ----"
# Show Complete and StreamWithTools blocks (best-effort via line ranges around their defs)
# First find function line numbers
rg -n "func \\(p \\*AnthropicProvider\\) (Complete|StreamWithTools)\\b" "$FILE"

# Print around those functions
for pat in "func (p \\*AnthropicProvider) Complete" "func (p \\*AnthropicProvider) StreamWithTools"; do
  ln=$(rg -n "$pat" "$FILE" | head -n1 | cut -d: -f1)
  start=$((ln-40)); end=$((ln+220))
  [ "$start" -lt 1 ] && start=1
  sed -n "${start},${end}p" "$FILE" | nl -ba
done

Repository: redhat-et/docsclaw

Length of output: 180


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="internal/anthropic/anthropic.go"

echo "---- FILE: $FILE (first 260 lines) ----"
sed -n '1,260p' "$FILE" | cat -n

echo "---- Tracing primitives usage ----"
echo "RecordError occurrences:"
rg -n "RecordError\\(" "$FILE" || true
echo "SetStatus occurrences:"
rg -n "SetStatus\\(" "$FILE" || true

echo "---- Find otel tracer Start sites ----"
rg -n "otel\\.Tracer\\(telemetry\\.TracerName\\)\\.Start" "$FILE" || true

echo "---- AnthropicProvider methods ----"
rg -n "func \\(p \\*AnthropicProvider\\) (Complete|StreamWithTools)\\b" "$FILE"

for fn in "Complete" "StreamWithTools"; do
  # Find function start line
  start=$(rg -n "func \\(p \\*AnthropicProvider\\) $fn\\b" "$FILE" | head -n1 | cut -d: -f1)
  echo "---- $fn: approx body (start at $start) ----"
  # Print a reasonable window; adjust if needed
  sed -n "$start,$((start+220))p" "$FILE" | cat -n
done

Repository: redhat-et/docsclaw

Length of output: 25394


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="internal/anthropic/anthropic.go"

echo "---- tail of file (last 220 lines) ----"
tail -n 220 "$FILE" | cat -n

echo "---- span.SetStatus occurrences ----"
rg -n "span\.SetStatus\\(" "$FILE" || true

echo "---- Complete() return error paths ----"
# show returns in Complete
awk '
  $0 ~ /^func \\(p \\*AnthropicProvider\\) Complete\\(/ {in=1}
  in && $0 ~ /^func \\(p \\*AnthropicProvider\\) / && $0 !~ /^func \\(p \\*AnthropicProvider\\) Complete\\(/ {in=0}
  in {print}
' "$FILE" | rg -n "return .*fmt\\.Errorf|return .*err" || true

echo "---- StreamWithTools() error-return paths (fmt.Errorf) ----"
awk '
  $0 ~ /^func \\(p \\*AnthropicProvider\\) StreamWithTools\\(/ {in=1}
  in && $0 ~ /^func \\(p \\*AnthropicProvider\\) / && $0 !~ /^func \\(p \\*AnthropicProvider\\) StreamWithTools\\(/ {in=0}
  in {print}
' "$FILE" | rg -n "return .*fmt\\.Errorf|return .*err" || true

Repository: redhat-et/docsclaw

Length of output: 8484


Mark failed Anthropic calls as failed spans.

(*AnthropicProvider).Complete returns multiple errors (API request failed, empty response from API) without setting any span status/error—so the deferred span.End() records them as successful spans.

(*AnthropicProvider).StreamWithTools only calls span.SetStatus(codes.Error, ...) on the stream.Err() path; earlier failure returns (e.g., accumulated.Accumulate(...) and tool json.Unmarshal(...)) end the span without error status (and without recording the error).

For manual OpenTelemetry Go instrumentation, mark failures with both span.RecordError(err) and span.SetStatus(codes.Error, ...) before ending the span, ideally via a deferred wrapper that runs on every return.

Suggested pattern
-func (p *AnthropicProvider) Complete(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
+func (p *AnthropicProvider) Complete(ctx context.Context, systemPrompt, userPrompt string) (_ string, err error) {
 	ctx, span := otel.Tracer(telemetry.TracerName).Start(ctx, "llm.api.call",
 		trace.WithAttributes(
 			telemetry.AttrLLMProvider.String(p.ProviderName()),
 			telemetry.AttrLLMModel.String(p.model),
 		))
-	defer span.End()
+	defer func() {
+		if err != nil {
+			span.RecordError(err)
+			span.SetStatus(codes.Error, err.Error())
+		}
+		span.End()
+	}()

Apply the same pattern to StreamWithTools so every error return is covered.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ctx, span := otel.Tracer(telemetry.TracerName).Start(ctx, "llm.api.call",
trace.WithAttributes(
telemetry.AttrLLMProvider.String(p.ProviderName()),
telemetry.AttrLLMModel.String(p.model),
))
defer span.End()
func (p *AnthropicProvider) Complete(ctx context.Context, systemPrompt, userPrompt string) (_ string, err error) {
ctx, span := otel.Tracer(telemetry.TracerName).Start(ctx, "llm.api.call",
trace.WithAttributes(
telemetry.AttrLLMProvider.String(p.ProviderName()),
telemetry.AttrLLMModel.String(p.model),
))
defer func() {
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
span.End()
}()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/anthropic/anthropic.go` around lines 63 - 68, Both Complete and
StreamWithTools create an OpenTelemetry span but do not consistently mark
failures; update these methods (methods: (*AnthropicProvider).Complete and
(*AnthropicProvider).StreamWithTools) so that every error return records the
error and sets the span status before span.End(): call span.RecordError(err) and
span.SetStatus(codes.Error, err.Error()) for any non-nil err. Implement this by
adding a deferred error-check wrapper immediately after span creation that
inspects a named return error (or a local err variable) and, if non-nil, calls
span.RecordError and span.SetStatus, ensuring the span is marked failed for all
early returns (also apply the same pattern to the stream error paths and
JSON/tooling error returns).

Comment thread internal/cmd/serve.go
Comment on lines +335 to +341
// Initialize OpenTelemetry tracing (no-op when disabled)
otelShutdown, err := telemetry.Init(context.Background(), telemetry.Config{
Enabled: cfg.OTel.Enabled,
CollectorEndpoint: cfg.OTel.CollectorEndpoint,
StdoutExporter: cfg.OTel.StdoutExporter,
ServiceVersion: version,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast when tracing is enabled without any exporter.

telemetry.Init only attaches a processor when StdoutExporter, CollectorEndpoint, or OTEL_EXPORTER_OTLP_ENDPOINT is set. With cfg.OTel.Enabled=true and none of those configured, startup succeeds but every span is silently dropped.

Suggested fix
 	// Initialize OpenTelemetry tracing (no-op when disabled)
+	if cfg.OTel.Enabled &&
+		!cfg.OTel.StdoutExporter &&
+		cfg.OTel.CollectorEndpoint == "" &&
+		os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") == "" {
+		return fmt.Errorf(
+			"OpenTelemetry enabled but no exporter configured; set --otel-stdout, --otel-collector-endpoint, or OTEL_EXPORTER_OTLP_ENDPOINT",
+		)
+	}
+
 	otelShutdown, err := telemetry.Init(context.Background(), telemetry.Config{
 		Enabled:           cfg.OTel.Enabled,
 		CollectorEndpoint: cfg.OTel.CollectorEndpoint,
 		StdoutExporter:    cfg.OTel.StdoutExporter,
 		ServiceVersion:    version,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Initialize OpenTelemetry tracing (no-op when disabled)
otelShutdown, err := telemetry.Init(context.Background(), telemetry.Config{
Enabled: cfg.OTel.Enabled,
CollectorEndpoint: cfg.OTel.CollectorEndpoint,
StdoutExporter: cfg.OTel.StdoutExporter,
ServiceVersion: version,
})
// Initialize OpenTelemetry tracing (no-op when disabled)
if cfg.OTel.Enabled &&
!cfg.OTel.StdoutExporter &&
cfg.OTel.CollectorEndpoint == "" &&
os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") == "" {
return fmt.Errorf(
"OpenTelemetry enabled but no exporter configured; set --otel-stdout, --otel-collector-endpoint, or OTEL_EXPORTER_OTLP_ENDPOINT",
)
}
otelShutdown, err := telemetry.Init(context.Background(), telemetry.Config{
Enabled: cfg.OTel.Enabled,
CollectorEndpoint: cfg.OTel.CollectorEndpoint,
StdoutExporter: cfg.OTel.StdoutExporter,
ServiceVersion: version,
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cmd/serve.go` around lines 335 - 341, When cfg.OTel.Enabled is true
but no exporter is configured (StdoutExporter, CollectorEndpoint, or
OTEL_EXPORTER_OTLP_ENDPOINT), telemetry.Init will create a no-op tracer and
silently drop spans; change the startup path around the telemetry.Init call to
detect this misconfiguration and fail fast: before calling telemetry.Init (or by
inspecting its return/status), check cfg.OTel.Enabled and whether
cfg.OTel.StdoutExporter or cfg.OTel.CollectorEndpoint or the
OTEL_EXPORTER_OTLP_ENDPOINT env var is set, and if none are present log a clear
error and exit; reference the telemetry.Init call and
cfg.OTel.Enabled/StdoutExporter/CollectorEndpoint symbols to locate where to add
this validation.

Comment thread internal/telemetry/content.go Outdated
Comment on lines +56 to +76
// OTLP gRPC exporter — sends to Jaeger, Tempo, or any OTLP collector.
// When CollectorEndpoint is empty, otlptracegrpc falls back to the
// standard OTEL_EXPORTER_OTLP_ENDPOINT env var (default: localhost:4317).
if cfg.CollectorEndpoint != "" || os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != "" {
grpcOpts := []otlptracegrpc.Option{}
if cfg.CollectorEndpoint != "" {
grpcOpts = append(grpcOpts, otlptracegrpc.WithEndpoint(cfg.CollectorEndpoint))
}
// Default to insecure for local dev; production should use TLS
// via OTEL_EXPORTER_OTLP_INSECURE=false or mTLS certs.
if os.Getenv("OTEL_EXPORTER_OTLP_INSECURE") == "" {
grpcOpts = append(grpcOpts, otlptracegrpc.WithInsecure())
}

otlpExp, otlpErr := otlptracegrpc.New(ctx, grpcOpts...)
if otlpErr != nil {
return noop, fmt.Errorf("creating OTLP exporter: %w", otlpErr)
}
opts = append(opts, sdktrace.WithBatcher(otlpExp))
slog.Info("OTel OTLP exporter configured", "endpoint", cfg.CollectorEndpoint)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In OpenTelemetry Go v1.44.0, what endpoint does otlptracegrpc.New(...)use when no endpoint option and noOTEL_EXPORTER_OTLP_ENDPOINT env var are provided?

💡 Result:

In OpenTelemetry Go v1.44.0, when no endpoint option and no OTEL_EXPORTER_OTLP_ENDPOINT (or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) environment variable are provided, the otlptracegrpc.New(...) function defaults to localhost:4317 [1][2]. While some documentation and community discussions may refer to this default as http://localhost:4317 [3], the exporter's internal implementation defaults to the host and port localhost:4317 [1][2]. Because it is a gRPC exporter, it does not use an HTTP/HTTPS scheme in the same way as an HTTP-based exporter; rather, it connects to this target using gRPC [1]. If no scheme is provided, gRPC defaults to using the dns scheme [4].

Citations:


--otel-enabled can silently drop every span (OTLP default never used)

internal/telemetry/telemetry.go only creates the OTLP exporter when cfg.CollectorEndpoint or OTEL_EXPORTER_OTLP_ENDPOINT is set. However, otlptracegrpc.New(...) defaults to localhost:4317 when no endpoint option and no relevant env var are provided, so OTLP-only configuration (e.g., Enabled=true and StdoutExporter=false) never exports spans—contradicting the inline fallback comment.

Suggested fix
 	// OTLP gRPC exporter — sends to Jaeger, Tempo, or any OTLP collector.
 	// When CollectorEndpoint is empty, otlptracegrpc falls back to the
 	// standard OTEL_EXPORTER_OTLP_ENDPOINT env var (default: localhost:4317).
-	if cfg.CollectorEndpoint != "" || os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != "" {
+	useOTLP := !cfg.StdoutExporter ||
+		cfg.CollectorEndpoint != "" ||
+		os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != ""
+	if useOTLP {
 		grpcOpts := []otlptracegrpc.Option{}
 		if cfg.CollectorEndpoint != "" {
 			grpcOpts = append(grpcOpts, otlptracegrpc.WithEndpoint(cfg.CollectorEndpoint))
 		}

Also consider gating on OTEL_EXPORTER_OTLP_TRACES_ENDPOINT so traces-specific endpoint config isn’t ignored.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// OTLP gRPC exporter — sends to Jaeger, Tempo, or any OTLP collector.
// When CollectorEndpoint is empty, otlptracegrpc falls back to the
// standard OTEL_EXPORTER_OTLP_ENDPOINT env var (default: localhost:4317).
if cfg.CollectorEndpoint != "" || os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != "" {
grpcOpts := []otlptracegrpc.Option{}
if cfg.CollectorEndpoint != "" {
grpcOpts = append(grpcOpts, otlptracegrpc.WithEndpoint(cfg.CollectorEndpoint))
}
// Default to insecure for local dev; production should use TLS
// via OTEL_EXPORTER_OTLP_INSECURE=false or mTLS certs.
if os.Getenv("OTEL_EXPORTER_OTLP_INSECURE") == "" {
grpcOpts = append(grpcOpts, otlptracegrpc.WithInsecure())
}
otlpExp, otlpErr := otlptracegrpc.New(ctx, grpcOpts...)
if otlpErr != nil {
return noop, fmt.Errorf("creating OTLP exporter: %w", otlpErr)
}
opts = append(opts, sdktrace.WithBatcher(otlpExp))
slog.Info("OTel OTLP exporter configured", "endpoint", cfg.CollectorEndpoint)
}
// OTLP gRPC exporter — sends to Jaeger, Tempo, or any OTLP collector.
// When CollectorEndpoint is empty, otlptracegrpc falls back to the
// standard OTEL_EXPORTER_OTLP_ENDPOINT env var (default: localhost:4317).
useOTLP := !cfg.StdoutExporter ||
cfg.CollectorEndpoint != "" ||
os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != ""
if useOTLP {
grpcOpts := []otlptracegrpc.Option{}
if cfg.CollectorEndpoint != "" {
grpcOpts = append(grpcOpts, otlptracegrpc.WithEndpoint(cfg.CollectorEndpoint))
}
// Default to insecure for local dev; production should use TLS
// via OTEL_EXPORTER_OTLP_INSECURE=false or mTLS certs.
if os.Getenv("OTEL_EXPORTER_OTLP_INSECURE") == "" {
grpcOpts = append(grpcOpts, otlptracegrpc.WithInsecure())
}
otlpExp, otlpErr := otlptracegrpc.New(ctx, grpcOpts...)
if otlpErr != nil {
return noop, fmt.Errorf("creating OTLP exporter: %w", otlpErr)
}
opts = append(opts, sdktrace.WithBatcher(otlpExp))
slog.Info("OTel OTLP exporter configured", "endpoint", cfg.CollectorEndpoint)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/telemetry/telemetry.go` around lines 56 - 76, The current condition
prevents creating the OTLP exporter when no endpoint env/flag is set, causing
spans to be dropped; update the creation logic so otlptracegrpc.New(...) is
called when traces should be exported even if no endpoint was provided: change
the if that guards exporter creation to true when cfg.Enabled &&
!cfg.StdoutExporter OR when cfg.CollectorEndpoint != "" OR env
OTEL_EXPORTER_OTLP_ENDPOINT != "" OR env OTEL_EXPORTER_OTLP_TRACES_ENDPOINT !=
""; keep the existing grpcOpts handling (append WithEndpoint when
cfg.CollectorEndpoint set, keep WithInsecure default behavior), call
otlptracegrpc.New(ctx, grpcOpts...) in that branch, and preserve the error
handling and slog.Info call (adjust log to include which env/flag was used if
you like).

Comment thread internal/telemetry/telemetry.go Outdated
Comment thread pkg/tools/loop_test.go
Comment thread pkg/tools/loop.go
Comment on lines +63 to 67
resp, err := provider.CompleteWithTools(iterCtx, messages, toolDefs)
if err != nil {
iterSpan.SetStatus(codes.Error, err.Error())
iterSpan.End()
return "", fmt.Errorf("LLM call failed: %w", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="pkg/tools/loop.go"
echo "=== File: $file ==="
echo "--- Lines 1-120 ---"
nl -ba "$file" | sed -n '1,120p'

echo "--- Lines 120-240 ---"
nl -ba "$file" | sed -n '120,240p'

Repository: redhat-et/docsclaw

Length of output: 175


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="pkg/tools/loop.go"
python3 - <<'PY'
import itertools
path="pkg/tools/loop.go"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()

def show(a,b):
    for i in range(a-1,b):
        if 0 <= i < len(lines):
            print(f"{i+1:4d}\t{lines[i].rstrip()}")

print("=== span creation area (approx 35-90) ===")
show(35,90)
print("\n=== tool denial path area (approx 130-210) ===")
show(130,210)
PY

Repository: redhat-et/docsclaw

Length of output: 5087


Set error status on tracing spans for both LLM failures and denied tool calls

  • pkg/tools/loop.go: when provider.CompleteWithTools fails, only iterSpan gets codes.Error; loopSpan ends via defer loopSpan.End() with no error status, so agent.loop can look successful (63-67).
  • pkg/tools/loop.go: on tool denial in executeTool, the tool.execute span sets denial attributes but never calls span.SetStatus(codes.Error, ...) (158-167).
Suggested fix
 		resp, err := provider.CompleteWithTools(iterCtx, messages, toolDefs)
 		if err != nil {
 			iterSpan.SetStatus(codes.Error, err.Error())
+			loopSpan.SetStatus(codes.Error, err.Error())
 			iterSpan.End()
 			return "", fmt.Errorf("LLM call failed: %w", err)
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
resp, err := provider.CompleteWithTools(iterCtx, messages, toolDefs)
if err != nil {
iterSpan.SetStatus(codes.Error, err.Error())
iterSpan.End()
return "", fmt.Errorf("LLM call failed: %w", err)
resp, err := provider.CompleteWithTools(iterCtx, messages, toolDefs)
if err != nil {
iterSpan.SetStatus(codes.Error, err.Error())
loopSpan.SetStatus(codes.Error, err.Error())
iterSpan.End()
return "", fmt.Errorf("LLM call failed: %w", err)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/tools/loop.go` around lines 63 - 67, When provider.CompleteWithTools
returns an error in agent.loop (the call to provider.CompleteWithTools), set the
higher-level loopSpan status to codes.Error before returning (in addition to
iterSpan.SetStatus) so loopSpan reflects the failure; similarly, in executeTool
where the "tool.execute" span marks a denied tool call via attributes, call
span.SetStatus(codes.Error, "<reason>") (or include err.Error() when available)
to mark the span as an error; update the code paths around
provider.CompleteWithTools, iterSpan, loopSpan, and executeTool/"tool.execute"
to call span.SetStatus(codes.Error, ...) with a concise error message before
ending/returning.

Comment thread pkg/tools/loop.go
Comment on lines 160 to 167
if !allow {
span.SetAttributes(
telemetry.AttrToolDenied.Bool(true),
telemetry.AttrToolDenyReason.String(reason),
)
slog.Warn("tool call denied by hook",
"tool", tc.Name, "reason", reason)
return Errorf("Tool call denied: %s", reason)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the file and print the relevant region with line numbers
if [ -f "pkg/tools/loop.go" ]; then
  echo "=== pkg/tools/loop.go (lines 130-220) ==="
  nl -ba pkg/tools/loop.go | sed -n '130,220p'
else
  echo "pkg/tools/loop.go not found"
  exit 1
fi

# Also search for span.SetStatus usages in the file
echo "=== span.SetStatus occurrences in pkg/tools/loop.go ==="
rg -n "span\.SetStatus" pkg/tools/loop.go || true

# Confirm whether otel codes is imported
echo "=== imports referencing otel codes ==="
rg -n "otel/codes" pkg/tools/loop.go || true

Repository: redhat-et/docsclaw

Length of output: 165


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Locate pkg/tools/loop.go ==="
ls -la pkg/tools/loop.go 2>/dev/null || true

echo "=== Find loop.go files ==="
fd -t f -n "loop.go" . || true

# Use awk for line numbers (nl not guaranteed)
FILE="pkg/tools/loop.go"
if [ -f "$FILE" ]; then
  echo "=== Showing $FILE around lines 140-210 (best-effort) ==="
  awk 'NR>=120 && NR<=230 {printf "%6d\t%s\n", NR, $0}' "$FILE" | sed -n '1,200p'

  echo "=== span.SetStatus occurrences in $FILE ==="
  rg -n "span\.SetStatus" "$FILE" || true

  echo "=== otel/codes occurrences in $FILE ==="
  rg -n "otel/codes|codes\." "$FILE" || true
else
  echo "=== $FILE not found; printing tree for pkg/tools (if exists) ==="
  ls -la pkg/tools 2>/dev/null || true
fi

Repository: redhat-et/docsclaw

Length of output: 4096


Mark denied tool calls as failed spans too

The hook-denied path sets denial attributes but never calls span.SetStatus(codes.Error, ...), unlike other failure paths in executeTool.

Suggested fix
 		if !allow {
 			span.SetAttributes(
 				telemetry.AttrToolDenied.Bool(true),
 				telemetry.AttrToolDenyReason.String(reason),
 			)
+			span.SetStatus(codes.Error, reason)
 			slog.Warn("tool call denied by hook",
 				"tool", tc.Name, "reason", reason)
 			return Errorf("Tool call denied: %s", reason)
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/tools/loop.go` around lines 160 - 167, The denied-tool path in
executeTool sets telemetry.AttrToolDenied and AttrToolDenyReason but does not
mark the span as failed; update the hook-denied branch (the block that calls
span.SetAttributes and slog.Warn for tc.Name) to also call
span.SetStatus(codes.Error, reason) (using the same status code pattern as other
failure paths) so the span is recorded as an error when a tool call is denied;
ensure the codes symbol used matches existing imports where other failures set
span status.

- Fix truncate to account for suffix length in byte budget so
  result never exceeds maxBytes
- Default OTLP gRPC to TLS; only use insecure when explicitly set
  to OTEL_EXPORTER_OTLP_INSECURE=true
- Save/restore global TracerProvider in test instead of always
  setting noop
- Set loopSpan error status when CompleteWithTools fails so parent
  span reflects child failure
- Add span error status to all error paths in both providers'
  Complete methods
- Give OTel shutdown its own timeout context separate from HTTP
  server shutdown

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Pavel Anni <panni@redhat.com>
@pavelanni pavelanni merged commit bdd8048 into main Jun 4, 2026
4 of 5 checks passed
@pavelanni pavelanni deleted the feature/otel-tracing branch June 4, 2026 01:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add OpenTelemetry tracing for agent execution

1 participant