Skip to content

feat: Add OpenTelemetry tracing to AG-UI endpoint - #1202

Closed
goyamegh wants to merge 10 commits into
HolmesGPT:masterfrom
goyamegh:feat/otel-tracing-agui
Closed

goyamegh wants to merge 10 commits into
HolmesGPT:masterfrom
goyamegh:feat/otel-tracing-agui

Conversation

@goyamegh

@goyamegh goyamegh commented Dec 18, 2025 •

Copy link
Copy Markdown

Summary

Add OpenTelemetry (OTEL) instrumentation to the experimental AG-UI chat endpoint for observability and distributed tracing. Traces are exported to AWS OSIS (OpenSearch Ingestion Service) using SigV4 authentication.

Motivation

  • Enable observability for the AG-UI chat endpoint to monitor agent runs and tool executions
  • Track tool call durations and success/failure rates
  • Support correlation across distributed traces using Gen AI semantic conventions
  • Integrate with AWS OpenSearch for trace storage and visualization

Key changes:

  • New experimental/otel module with tracer initialization and attributes
  • Instrument AG-UI /api/agui/chat endpoint with root spans and tool spans
  • Support AWS SigV4 authentication for OSIS endpoints
  • Follow Gen AI semantic conventions for span attributes
  • Add unit tests and integration tests with OpenSearch verification

Environment variables:

  • OTEL_ENABLED: Set to 'true' to enable tracing
  • OTEL_EXPORTER_OTLP_ENDPOINT: OSIS pipeline URL
  • OTEL_AWS_PROFILE: Optional AWS profile for OSIS auth
  • OTEL_AWS_REGION: Optional region (auto-detected from endpoint)

Changes

New Module: experimental/otel/

  • tracing.py: Tracer initialization with AWS SigV4 auth for OSIS endpoints
  • attributes.py: Gen AI semantic convention attribute constants (REQUEST_ID, TOOL_NAME, etc.)
  • __init__.py: Package exports

AG-UI Integration (server-agui.py)

  • Root span (agent.run) created for each chat request with correlation attributes
  • Child spans (tool.execute) for each tool call with duration tracking
  • Error recording on spans when exceptions occur

Tests

  • test_otel.py: Unit tests (no external dependencies required)
  • test_otel_integration.py: Integration tests with real OSIS endpoint + OpenSearch verification

Dependencies

  • Added opentelemetry-api, opentelemetry-sdk, opentelemetry-exporter-otlp-proto-http

Configuration

Environment variables (all optional - tracing disabled by default):

Variable Description
OTEL_ENABLED Set to true to enable tracing
OTEL_EXPORTER_OTLP_ENDPOINT OSIS pipeline URL (e.g., https://xxx.us-east-1.osis.amazonaws.com/path/v1/traces)
OTEL_AWS_PROFILE AWS profile for OSIS authentication (optional)
OTEL_AWS_REGION AWS region (optional, auto-detected from endpoint)
OTEL_SERVICE_NAME Service name in traces (default: holmesgpt)

Testing

Unit Tests

poetry run python experimental/otel/test_otel.py
Output:
============================================================
OTEL Module Verification
============================================================

Testing imports...
  ✅ All imports successful

Testing truncate function...
  ✅ truncate(None) returns ''
  ✅ truncate('short string') returns unchanged
  ✅ truncate(string of 8192 chars) returns unchanged
  ✅ truncate(string of 8292 chars) truncates correctly
  ✅ truncate('') returns ''

Testing region extraction...
  ✅ Extracts 'us-west-2' from valid OSIS endpoint
  ✅ Extracts 'eu-central-1' from valid OSIS endpoint
  ✅ Falls back to 'us-east-1' for non-OSIS endpoint
  ✅ Falls back to 'us-east-1' for malformed URL
  ✅ Falls back to 'us-east-1' for empty string

Testing tracer initialization (disabled)...
  ✅ init_otel_tracer() returns False when OTEL_ENABLED not set
  ✅ get_tracer() returns a no-op tracer when disabled
  ✅ No-op tracer can create and end spans without error

Testing set_span_error...
  ✅ set_span_error() handles exceptions without raising

Testing attribute constants...
  ✅ Gen AI standard attributes defined correctly
  ✅ Tool attributes defined correctly
  ✅ Span names defined correctly

Testing server-agui.py compatibility...
  ✅ All attributes used by server-agui.py are available

============================================================
✅ All 7 tests passed!
============================================================

Integration Tests (with real OSIS endpoint)

  export OTEL_ENABLED=true
  export OTEL_EXPORTER_OTLP_ENDPOINT=https://xxx.us-east-1.osis.amazonaws.com/path/v1/traces
  export OTEL_AWS_PROFILE=your-profile
  export OPENSEARCH_ENDPOINT=https://your-opensearch.on.aws
  export OPENSEARCH_USERNAME=admin
  export OPENSEARCH_PASSWORD=your-password

  poetry run python experimental/otel/test_otel_integration.py
  Output:
  ============================================================
  OTEL Integration Test with OSIS + OpenSearch Verification
  ============================================================

  ✅ Environment configured correctly
  ✅ Tracer initialized successfully
  ✅ All spans created successfully
  ✅ Root span ended
  ✅ Error span test completed
  ✅ Spans flushed successfully

  Test: Verify Traces in OpenSearch
✅ Found 5 traces in index 'otel-v1-apm-span-*'!

Sample trace data:
  - Trace ID: 622865aacd67a42a691020935a1be700
    Span Name: agent.run
    Service: holmesgpt
  - Trace ID: a9eb3f375f5ca2688ceeec5b1500ff94
    Span Name: tool.execute
    Service: holmesgpt

  ============================================================
  ✅ All 4 tests passed!
  ============================================================

  Pre-commit Checks

  All checks pass:
  poetry-check.............................................................Passed
  ruff.....................................................................Passed
  ruff-format..............................................................Passed
  detect private key.......................................................Passed
  fix end of files.........................................................Passed
  mypy.....................................................................Passed

Notes

  • Tracing is disabled by default - no impact unless OTEL_ENABLED=true
  • Supports separate AWS profiles for OSIS vs other services (e.g., Bedrock)
  • Thread safety warning added for env var manipulation during startup
  • No hardcoded credentials - all sensitive config via environment variables

Summary by CodeRabbit

  • New Features

    • Optional OpenTelemetry tracing, metrics, and correlated logging across agent runs, per-iteration LLM activity, and tool invocations; server middleware emits request-level traces and spans.
    • New structured streaming events for iterations, tool invoke start/end, response parsing, context checks, and error handling.
  • Documentation

    • Added OTEL tracing docs with configuration, span/metric conventions, examples, and troubleshooting.
  • Tests

    • Added unit and integration tests for tracing, metrics, logging, and end-to-end export/verification.
  • Chores

    • Added OTEL packages and refined config loading/merging behavior and logging level when loading configs.

@CLAassistant

CLAassistant commented Dec 18, 2025 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Dec 18, 2025 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds an experimental OpenTelemetry package (tracing, metrics, logging), wires OTEL into server and AG‑UI agent flows with per-run / per-iteration / per-tool spans and streaming events, extends TracingFactory for OTEL/composite tracers, and adds tests, docs, and dependencies.

Changes

Cohort / File(s) Summary
OTEL Core & API
experimental/otel/__init__.py, experimental/otel/attributes.py, experimental/otel/tracing.py, experimental/otel/metrics.py, experimental/otel/otel_logging.py
New experimental OTEL package: central exports, semantic attribute constants and truncate util, tracer init/shutdown with OTLP/OSIS + optional AWS SigV4 signing, metrics init and instruments, OTEL-aware logging formatter and structured logging helpers.
OTEL Tests & Integration
experimental/otel/test_otel.py, experimental/otel/test_otel_integration.py, experimental/otel/test_*.py
New unit and integration test suites verifying imports, truncate, tracer/metrics init, span creation/export, error spans, and optional OpenSearch verification.
Server / AG-UI Integration
experimental/ag-ui/server-agui.py, server.py
Integrates OTEL initialization and middleware; starts root spans for requests/agent runs; emits trace context on stream events and attempts metrics init; conditional no-op behavior when OTEL disabled.
Tracing subsystem & factory
holmes/core/tracing.py
Adds OTELSpan/OTELTracer, CompositeSpan/CompositeTracer, TracingFactory.init_otel and multi-tracer support, and mapping from SpanType → OTEL semantic names with guarded experimental imports.
Tool-calling & Streaming Events
holmes/core/tool_calling_llm.py, holmes/utils/stream.py
Adds granular streaming events and builders (context checks, LLM iteration start/complete, parse response, tool invoke start/end, error handling) and emits them during streaming and non-streaming call flows.
Logging / Telemetry usage
experimental/otel/otel_logging.py, holmes/core/tool_calling_llm.py, experimental/ag-ui/server-agui.py
OTEL-correlated logging formatter and structured logging helpers; logging calls added alongside span/metric instrumentation for tokens, durations, tool counts, and errors.
Docs & Dependencies
docs/otel-tracing.md, pyproject.toml
Adds OTEL tracing documentation and examples; adds dependencies: opentelemetry-api, opentelemetry-sdk, opentelemetry-exporter-otlp-proto-http.
Config & Utilities
holmes/config.py, holmes/utils/pydantic_utils.py, holmes/utils/stream.py
Config load_from_env merges file config with env overrides; small pydantic utilities added; stream module expanded with new event enums and builders.
Tests & Test Adjustments
tests/*, tests/utils/*
Adjustments to tests to include usage/finish_reason metadata and many new tracing tests; added/updated test expectations to accommodate OTEL instrumentation.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant AGUI as AG-UI Server
    participant Tracer as OTEL Tracer
    participant Exporter as OTLP/OSIS Backend

    Client->>AGUI: start chat / agent run
    activate AGUI

    AGUI->>Tracer: init_otel_tracer() (if enabled)
    Tracer->>Exporter: configure exporter (OTLP or SigV4)
    Tracer-->>AGUI: tracer ready

    AGUI->>Tracer: start_span("agent_run", attrs...)
    activate Tracer

    loop each LLM iteration
      AGUI->>Tracer: start_span("llm_iteration")
      AGUI->>Tracer: end_span("llm_iteration", token/cost attrs)
      alt tool required
        AGUI->>Tracer: start_span("tool_execute", TOOL_NAME)
        AGUI->>Tracer: end_span("tool_execute", duration/output/error)
      end
    end

    alt success
      AGUI->>Tracer: set attribute RESULT_SUCCESS=true
    else error
      AGUI->>Tracer: set_span_error(exception)
    end

    AGUI->>Tracer: end_span("agent_run")
    Tracer->>Exporter: export / flush spans
    AGUI-->>Client: stream/return response
    deactivate AGUI
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • RoiGlinik
  • arikalon1
  • aantn
🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'feat: Add OpenTelemetry tracing to AG-UI endpoint' is partially related to the changeset. While OTEL tracing is a key focus, the PR encompasses far broader changes beyond AG-UI integration, including a complete new experimental/otel module, tracing infrastructure, metrics, logging, and integration into core holmes/core/tracing.py and server.py. The title emphasizes only the AG-UI endpoint change, not the foundational OTEL infrastructure that represents a substantial portion of the work. Consider updating the title to reflect the full scope of changes, such as 'feat: Add OpenTelemetry instrumentation with tracing, metrics, and logging' or 'feat: Integrate OpenTelemetry observability across core and experimental modules', as suggested in reviewer feedback.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (9)
experimental/otel/tracing.py (2)

158-159: Debug logging may expose sensitive headers.

The Authorization header (containing AWS credentials signature) will be logged. While debug level, this could leak to log files in production if log levels are misconfigured.

🔎 Consider masking sensitive headers:
-        # Debug: show signed headers
-        logging.debug(f"[OTEL SIGN] Headers after signing: {dict(aws_request.headers)}")
+        # Debug: show signed headers (mask Authorization)
+        debug_headers = {k: v if k.lower() != 'authorization' else '[MASKED]' 
+                        for k, v in aws_request.headers.items()}
+        logging.debug(f"[OTEL SIGN] Headers after signing: {debug_headers}")

351-360: Use attribute constants for consistency.

set_span_error uses hardcoded "error.type" and "error.message" while attributes.py defines ERROR_TYPE and ERROR_MESSAGE constants. Using the constants ensures consistency.

🔎 Apply this diff:
+from experimental.otel.attributes import ERROR_TYPE, ERROR_MESSAGE
+
 def set_span_error(span: trace.Span, error: Exception) -> None:
     """Set error status and attributes on a span.

     Args:
         span: The span to set error on
         error: The exception that occurred
     """
     span.set_status(Status(StatusCode.ERROR, str(error)))
-    span.set_attribute("error.type", type(error).__name__)
-    span.set_attribute("error.message", str(error))
+    span.set_attribute(ERROR_TYPE, type(error).__name__)
+    span.set_attribute(ERROR_MESSAGE, str(error))
experimental/otel/attributes.py (1)

55-72: Truncation result exceeds max_size.

The function truncates to max_size then appends "...[TRUNCATED]" (14 chars), so the result is max_size + 14 bytes. If the goal is to enforce a strict size limit, account for the marker length.

🔎 Strict size enforcement:
+TRUNCATION_MARKER = "...[TRUNCATED]"
+
 def truncate(value: Optional[str], max_size: int = MAX_ATTRIBUTE_SIZE) -> str:
     if value is None:
         return ""
     if len(value) <= max_size:
         return value
-    return value[:max_size] + "...[TRUNCATED]"
+    return value[:max_size - len(TRUNCATION_MARKER)] + TRUNCATION_MARKER
experimental/ag-ui/server-agui.py (1)

21-29: Consider proper package structure over sys.path manipulation.

The sys.path.insert is fragile and can cause import issues. Since this is experimental code, it's acceptable, but consider adding the experimental directory as a proper package or using relative imports when stabilizing.

experimental/otel/test_otel.py (2)

1-9: Consider converting to pytest format.

The script works for quick verification but integrating with pytest would provide better CI/CD integration, fixtures for setup/teardown, and consistent test discovery.


113-117: Direct module state manipulation is fragile.

Modifying tracing._initialized and _tracer_provider directly works but is brittle. When converting to pytest, consider using importlib.reload() or providing a proper reset() function in the tracing module for testing.

experimental/otel/__init__.py (1)

1-51: Clean package API surface design.

This module correctly aggregates and re-exports the public OTEL instrumentation API. The explicit __all__ makes the public interface clear.

Optionally, consider sorting __all__ alphabetically for easier maintenance as the list grows (per static analysis hint RUF022).

experimental/otel/test_otel_integration.py (2)

111-116: Fragile coupling to private module state.

Directly resetting tracing._initialized and tracing._tracer_provider couples this test to internal implementation details. If the module internals change (e.g., renamed variables, different state management), this test will break silently or require updates.

Consider exposing a reset_tracer() test utility in the tracing module if this pattern is needed elsewhere, or document this coupling with a comment.


255-261: Direct access to private _tracer_provider.

Similar to test_tracer_initialization, this accesses private module state. Consider whether shutdown_otel_tracer() (which is publicly exported) could be used, or expose a force_flush() wrapper in the public API.

The 10-second timeout for force_flush is reasonable for integration tests.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3f1d3a3 and 28dd21cd4ee9f45995101ca275ea1d4f8fb0d9ab.

⛔ Files ignored due to path filters (1)
  • poetry.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • experimental/ag-ui/server-agui.py (8 hunks)
  • experimental/otel/__init__.py (1 hunks)
  • experimental/otel/attributes.py (1 hunks)
  • experimental/otel/test_otel.py (1 hunks)
  • experimental/otel/test_otel_integration.py (1 hunks)
  • experimental/otel/tracing.py (1 hunks)
  • pyproject.toml (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
experimental/otel/test_otel.py (3)
experimental/otel/tracing.py (1)
  • _extract_region_from_endpoint (71-90)
experimental/otel/attributes.py (1)
  • truncate (55-72)
holmes/core/tracing.py (2)
  • start_span (104-105)
  • end (110-111)
experimental/otel/test_otel_integration.py (2)
experimental/otel/tracing.py (3)
  • init_otel_tracer (241-323)
  • get_tracer (326-340)
  • set_span_error (351-360)
experimental/otel/attributes.py (1)
  • truncate (55-72)
experimental/ag-ui/server-agui.py (3)
experimental/otel/tracing.py (3)
  • init_otel_tracer (241-323)
  • get_tracer (326-340)
  • set_span_error (351-360)
holmes/core/tracing.py (2)
  • start_span (104-105)
  • end (110-111)
experimental/otel/attributes.py (1)
  • truncate (55-72)
experimental/otel/__init__.py (2)
experimental/otel/tracing.py (4)
  • init_otel_tracer (241-323)
  • get_tracer (326-340)
  • shutdown_otel_tracer (343-348)
  • set_span_error (351-360)
experimental/otel/attributes.py (1)
  • truncate (55-72)
experimental/otel/tracing.py (1)
holmes/version.py (1)
  • get_version (48-130)
🪛 Ruff (0.14.8)
experimental/otel/test_otel.py

225-225: Do not catch blind exception: Exception

(BLE001)

experimental/otel/test_otel_integration.py

235-235: Abstract raise to an inner function

(TRY301)


235-235: Avoid specifying long messages outside the exception class

(TRY003)


237-237: Do not catch blind exception: Exception

(BLE001)


264-264: Consider moving this statement to an else block

(TRY300)


265-265: Do not catch blind exception: Exception

(BLE001)


453-453: Do not catch blind exception: Exception

(BLE001)


485-485: Do not catch blind exception: Exception

(BLE001)

experimental/ag-ui/server-agui.py

294-294: Use explicit conversion flag

Replace with conversion flag

(RUF010)

experimental/otel/__init__.py

29-51: __all__ is not sorted

Apply an isort-style sorting to __all__

(RUF022)

experimental/otel/tracing.py

88-89: try-except-pass detected, consider logging the exception

(S110)


88-88: Do not catch blind exception: Exception

(BLE001)


106-106: Avoid specifying long messages outside the exception class

(TRY003)


234-234: Consider moving this statement to an else block

(TRY300)


318-318: Consider moving this statement to an else block

(TRY300)


369-369: Do not catch blind exception: Exception

(BLE001)

🔇 Additional comments (16)
experimental/otel/tracing.py (2)

181-213: Thread-safety concern is well-documented.

The environment variable manipulation for profile isolation is a known limitation. The documentation is clear about startup-only usage. Consider adding an assertion or runtime check to prevent accidental concurrent calls if this becomes a concern.


264-272: Initialization semantics are correct.

Setting _initialized = True even when disabled or missing endpoint is intentional — it prevents repeated initialization attempts on subsequent get_tracer() calls. The early returns with False correctly indicate tracing won't be active.

experimental/otel/attributes.py (1)

8-48: Well-structured attribute constants.

The constants follow Gen AI semantic conventions and are clearly organized by category. Good documentation linking to AG-UI field mappings.

experimental/ag-ui/server-agui.py (4)

85-91: LGTM - Tracer initialization at startup.

Module-level initialization ensures tracing is configured before handling requests. The get_tracer() call safely returns a no-op tracer when disabled.


142-153: LGTM - Root span with correlation attributes.

Creating the span inside the generator ensures it covers the streaming lifecycle. The correlation attributes (REQUEST_ID, CONVERSATION_ID) properly link traces to AG-UI request/thread identifiers.


208-229: LGTM - Tool execution spans with proper parent-child linking.

The implementation correctly:

  • Links child spans to the root using trace.set_span_in_context
  • Calculates duration from tracked start times
  • Truncates potentially large tool outputs
  • Handles missing start times gracefully with pop(..., None)

288-298: LGTM - Robust error handling and span lifecycle.

The finally block ensures the root span is always ended, preventing resource leaks. Error details are properly recorded on the span before the error event is yielded.

experimental/otel/test_otel.py (2)

40-71: Good edge case coverage for truncate.

Tests cover all key scenarios: None input, strings within limit, at exact limit, over limit, and empty strings. The assertions include helpful failure messages.


220-228: Test runner correctly aggregates failures.

The broad Exception catch is intentional here to ensure all tests run even if one fails. Consider adding traceback.print_exc() for better debugging of failures.

experimental/otel/test_otel_integration.py (7)

1-28: Good documentation for integration test usage.

The docstring clearly documents all required and optional environment variables, usage instructions, and the purpose of the test. This helps developers run the test correctly.


69-73: Good practice: Masking sensitive credentials in output.

The password is correctly masked with "***" to prevent accidental exposure in logs/output.


180-194: Correct span hierarchy and context propagation.

Child tool spans are properly created with trace.set_span_in_context(root_span) ensuring correct parent-child relationships in the trace. Each tool span is correctly ended within the loop.


230-243: Intentional error simulation for testing.

The broad Exception catch and intentional ValueError raise are appropriate here—this is a test exercising the error-recording path. The finally block correctly ensures the span is ended regardless of outcome.


344-349: Good retry pattern with exponential backoff potential.

The retry loop with configurable max_retries and retry_delay is appropriate for handling indexing delays. All HTTP requests correctly use timeout=10 to prevent hangs.


489-500: Good conditional verification with credential check.

The OpenSearch verification is correctly gated behind a check for all three required variables (OPENSEARCH_ENDPOINT, OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD), and failure doesn't break the overall test result—appropriate for optional verification that may have indexing delays.


273-276: Credentials sourced from environment variables.

OpenSearch credentials are correctly read from environment variables rather than hardcoded. Empty string defaults are appropriate since the verification is optional.

Comment thread experimental/otel/test_otel_integration.py
Comment thread pyproject.toml Outdated
@kylehounslow

Copy link
Copy Markdown
Contributor

@goyamegh Is it possible to instead add this to existing tracing module so it's pluggable like Braintrust tracer?

class BraintrustTracer:

Relevant PR: #1331

@aantn

aantn commented Jan 13, 2026

Copy link
Copy Markdown
Collaborator

Agree with the comment from @kylehounslow - I think that's the best way to do this.
If you can add it that way cleanly, we'd love to get this merged.

@goyamegh
goyamegh force-pushed the feat/otel-tracing-agui branch from 28dd21c to 264cb68 Compare January 14, 2026 23:05
@netlify

netlify Bot commented Jan 14, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for holmes-docs ready!

Name Link
🔨 Latest commit 9e8570d
🔍 Latest deploy log https://app.netlify.com/projects/holmes-docs/deploys/69afc6b9d1c6820008c87651
😎 Deploy Preview https://deploy-preview-1202--holmes-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@linux-foundation-easycla

linux-foundation-easycla Bot commented Jan 14, 2026 •

Copy link
Copy Markdown

CLA Signed

The committers listed above are authorized under a signed CLA.

  • ✅ login: goyamegh / name: Megha Goyal (9e8570d)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@experimental/ag-ui/server-agui.py`:
- Around line 189-191: The variable tool_start_times is annotated as dict[str,
float] but is being used to store span objects; create a separate dict (e.g.,
active_spans: dict[str, Span]) to hold span objects and keep tool_start_times
strictly for float timestamps, update the initialization to: tool_start_times:
dict[str, float] = {} and active_spans: dict[str, Span] = {}, then replace every
place that stores or retrieves spans (keys like "invoke_{tool_call_id}",
"parse_response", etc.) to use active_spans while leaving timestamp calculations
on tool_start_times so type hints and usages match (adjust imports/types if
needed and update code references in functions that start/finish spans or
compute durations such as start/invoke/finish handlers).
- Around line 536-566: Exception paths currently only set errors and end
current_chat_span and root_span, leaving spans held in tool_start_times (keys
like "invoke", "parse_response", "context_check", "error_handling") unclosed;
ensure you iterate over tool_start_times in the finally block and for each span
call set_span_error(span, e) if an exception occurred (or mark success
otherwise) and end() the span, and also clear or reset tool_start_times after
closing; update finally to close all spans (referencing tool_start_times,
set_span_error, current_chat_span, root_span) so no spans remain open on error
or normal exit.

In `@holmes/core/tool_calling_llm.py`:
- Around line 783-789: The tokens_used value is computed incorrectly; instead of
subtracting maximum_output_token from max_context_size, use the actual consumed
tokens from limit_result.tokens.total_tokens (or a safe fallback if tokens or
total_tokens is missing). Update the build_stream_event_context_check call in
the block using limit_result.tokens.total_tokens for tokens_used, keep
tokens_limit as limit_result.max_context_size (with the existing hasattr
checks), and ensure you handle missing attributes without raising (e.g., None
fallback).

In `@pyproject.toml`:
- Around line 58-60: The OpenTelemetry dependencies (opentelemetry-api,
opentelemetry-sdk, opentelemetry-exporter-otlp-proto-http) are pinned to
^1.29.0; update each dependency version specifier to ^1.39.1 in pyproject.toml
so the project uses the latest stable release (replace the current ^1.29.0
entries for opentelemetry-api, opentelemetry-sdk, and
opentelemetry-exporter-otlp-proto-http with ^1.39.1).
🧹 Nitpick comments (26)
holmes/config.py (1)

175-231: LGTM! Clear file-then-env merge strategy.

The implementation correctly:

  • Loads from ~/.holmes/config.yaml if present
  • Overlays environment variable overrides
  • Uses model_dump() for Pydantic v2 compatibility

One minor inconsistency: load_from_file (line 166) uses the deprecated .dict() method while this method uses .model_dump(). Consider updating line 166 for consistency.

docs/otel-tracing.md (2)

7-11: Add blank line before list for proper MkDocs rendering.

As per coding guidelines, MkDocs requires a blank line between header/bold text and a list for proper rendering.

📝 Proposed fix
 HolmesGPT supports comprehensive observability through OpenTelemetry, enabling:
+
 - **Distributed Tracing**: Track requests across the entire agent execution lifecycle
 - **Metrics**: Monitor token usage, operation durations, and error rates
 - **Structured Logging**: Correlate logs with trace context

254-266: Consider using proper headings instead of bold emphasis for issue titles.

The troubleshooting subsections use bold text (**"error message"**) which markdownlint flags as emphasis-as-heading. Using #### headings would improve navigation and accessibility.

📝 Proposed fix
 ### Common Issues
 
-**"OTEL tracing requested but OTEL_ENABLED not set to 'true'"**
+#### "OTEL tracing requested but OTEL_ENABLED not set to 'true'"
 
 Set `OTEL_ENABLED=true` in your environment.
 
-**"Failed to create OSIS session"**
+#### "Failed to create OSIS session"
 
 Check your AWS credentials and ensure `OTEL_AWS_PROFILE` points to a valid profile with OSIS permissions.
 
-**"payload too large" errors**
+#### "payload too large" errors
 
 Tool outputs are automatically truncated to 8KB. If you still see this error, check for large metadata values.
experimental/otel/tracing.py (4)

71-96: LoggingSpanExporter doesn't inherit from SpanExporter, causing type mismatch at line 369.

The wrapper class doesn't implement the SpanExporter interface, which means passing it to BatchSpanProcessor (line 376-381) relies on duck typing. This works but loses type safety.

♻️ Proposed fix
+from opentelemetry.sdk.trace.export import SpanExporter
+
-class LoggingSpanExporter:
+class LoggingSpanExporter(SpanExporter):
     """Wrapper around SpanExporter that logs export results."""

     def __init__(self, wrapped_exporter: OTLPSpanExporter):
         self._wrapped = wrapped_exporter

98-110: Consider consolidating duplicate helper functions into a shared module.

_get_otel_enabled() and _get_otel_endpoint() are duplicated across tracing.py, metrics.py, and otel_logging.py. This violates DRY and creates maintenance burden.

Consider creating experimental/otel/config.py to centralize environment variable access.


138-158: Log exceptions instead of silently passing.

The except block silently swallows all exceptions, making debugging difficult when region extraction fails unexpectedly.

♻️ Proposed fix
     try:
         parsed = urlparse(endpoint)
         if not parsed.hostname:
             logging.warning(
                 f"[OTEL] Could not parse hostname from endpoint: {endpoint}"
             )
             return "us-east-1"
         host_parts = parsed.hostname.split(".")
         # OSIS endpoints look like: xxx.<region>.osis.amazonaws.com
         for i, part in enumerate(host_parts):
             if part == "osis" and i > 0:
                 return host_parts[i - 1]
-    except Exception:
-        pass
+    except Exception as e:
+        logging.debug(f"[OTEL] Failed to extract region from endpoint: {e}")
     return "us-east-1"  # Default fallback

252-284: Thread-safety risk acknowledged but consider alternative approach.

The env var manipulation creates a TOCTOU race condition. While the warning is well-documented, consider using botocore.session.Session directly with explicit credential configuration instead of clearing env vars:

from botocore.session import Session as BotocoreSession
session = BotocoreSession()
session.set_config_variable('profile', otel_profile)
boto_session = boto3.Session(botocore_session=session)

This avoids env var manipulation entirely.

experimental/otel/metrics.py (1)

77-91: Fragile endpoint detection using string matching.

The check ".osis." in endpoint or ".es." in endpoint is brittle and may fail for:

  • Custom OSIS domains
  • Endpoints with "osis" or "es" in the path (false positive)
  • Future AWS endpoint format changes

Consider using the same OTEL_AWS_PROFILE check as the primary signal, or add an explicit OTEL_AWS_SIGV4_ENABLED env var.

♻️ Proposed fix
-        if aws_profile or ".osis." in endpoint or ".es." in endpoint:
+        # Use explicit env var or profile presence to determine SigV4 need
+        use_sigv4 = aws_profile or os.environ.get("OTEL_AWS_SIGV4_ENABLED", "false").lower() == "true"
+        if use_sigv4:
holmes/utils/stream.py (1)

271-274: Consider truncating the error field for consistency.

The result field is truncated at 8192 characters, but error is not. The build_stream_event_error_handling function truncates error_message at 1024. Consider applying similar truncation to error here to prevent large payloads from verbose error messages.

♻️ Suggested fix
     if error is not None:
-        data["error"] = error
+        data["error"] = error[:1024] if len(error) > 1024 else error
server.py (1)

166-166: Performance: Create tracer once at module level, not per request.

TracingFactory.create_tracer("otel") is called on every request. The tracer should be created once at startup and reused. This avoids repeated lookups and object creation on the hot path.

♻️ Suggested fix
 otel_enabled = init_otel()
+otel_tracer = TracingFactory.create_tracer("otel") if otel_enabled else None
 config = Config.load_from_env()

Then in the middleware:

-        tracer = TracingFactory.create_tracer("otel")
+        tracer = otel_tracer
experimental/ag-ui/server-agui.py (2)

24-26: Consider avoiding sys.path manipulation.

Manipulating sys.path is a code smell that can lead to import issues and makes the module harder to maintain. Consider:

  1. Installing the experimental module as a package
  2. Using relative imports if the module structure allows
  3. Adding the experimental path to PYTHONPATH in the deployment configuration

562-562: Style: Use explicit conversion flag instead of str().

Per Ruff RUF010, prefer {e!s} over {str(e)} for string conversion in f-strings.

♻️ Suggested fix
-                    message=f"Agent encountered an error: {str(e)}",
+                    message=f"Agent encountered an error: {e!s}",
holmes/core/tool_calling_llm.py (1)

966-966: Consider tracking actual duration or omitting duration_ms.

duration_ms=0 is always emitted which is misleading. The comment says duration is tracked separately, but emitting a constant 0 pollutes the trace data. Either track the actual duration between tool_invoke_start and tool_invoke_end, or omit the field.

♻️ Option 1: Track duration

Store the start time when emitting tool_invoke_start:

tool_start_time = time.time()
yield build_stream_event_tool_invoke_start(...)
# ... tool execution ...
duration_ms = int((time.time() - tool_start_time) * 1000)
yield build_stream_event_tool_invoke_end(..., duration_ms=duration_ms, ...)
♻️ Option 2: Make duration optional

Modify build_stream_event_tool_invoke_end to make duration_ms optional and omit it when not tracked.

experimental/otel/test_otel_integration.py (5)

38-41: Consider using a more robust path manipulation approach.

The sys.path.insert(0, ...) pattern can lead to import order issues. For test files, consider using pytest with proper package configuration or a conftest.py that handles path setup.


111-116: Direct manipulation of private module state is fragile.

Resetting _initialized and _tracer_provider directly couples this test to internal implementation details. If the module's internals change, these tests will break silently.

Consider adding a public reset_tracer() function to the tracing module for test purposes, or use importlib to reload the module cleanly.


129-130: Global mutable state for test coordination.

Using a module-level global _current_test_id makes tests harder to parallelize and can cause subtle bugs if test order changes.

Consider returning the test_id from test_create_and_export_spans() and passing it explicitly to verify_traces_in_opensearch().


255-255: Accessing private variable from another module.

_tracer_provider is a private module variable. This tight coupling could break if the tracing module's internals change.

Consider exposing a public force_flush() function in the tracing module API instead.

♻️ Suggested approach

Add to experimental/otel/tracing.py:

def force_flush(timeout_millis: int = 10000) -> bool:
    """Force flush all pending spans to the backend."""
    if _tracer_provider:
        return _tracer_provider.force_flush(timeout_millis=timeout_millis)
    return False

Then in the test:

-from experimental.otel.tracing import _tracer_provider
+from experimental.otel.tracing import force_flush

-if _tracer_provider:
-    _tracer_provider.force_flush(timeout_millis=10000)
+force_flush(timeout_millis=10000)

341-341: Credentials passed in plain tuple - consider auth abstraction.

While functional, passing credentials as a tuple (username, password) is common but consider using requests.auth.HTTPBasicAuth explicitly for clarity.

experimental/otel/test_otel.py (3)

7-8: Same sys.path manipulation concern as integration test.

Consider a shared test utility or conftest.py for path setup.


151-158: Repeated pattern of manipulating internal module state.

Multiple tests reset _initialized, _tracer_provider, _meter, _meter_provider directly. This creates maintenance burden and tight coupling to implementation details.

Consider creating a test utilities module with reset functions, or exposing test hooks in the OTEL modules.

Also applies to: 271-281


571-594: Fragile tests that parse source files.

These tests read server.py and server-agui.py as text and check for exact string matches. This approach:

  1. Breaks if imports are reformatted (e.g., multi-line imports)
  2. Doesn't verify actual runtime behavior
  3. Creates a brittle dependency on code formatting

Consider using AST parsing or importlib inspection instead, or simply rely on actual integration tests that exercise the code paths.

♻️ Alternative using AST
import ast

def test_server_uses_tracing_factory():
    """Test that server.py uses TracingFactory correctly."""
    print("Testing server.py TracingFactory usage...")
    
    server_path = os.path.join(
        os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
        "server.py"
    )
    
    with open(server_path, "r") as f:
        tree = ast.parse(f.read())
    
    # Check for TracingFactory import
    imports = [node for node in ast.walk(tree) if isinstance(node, ast.ImportFrom)]
    has_tracing_import = any(
        node.module == "holmes.core.tracing" and 
        any(alias.name == "TracingFactory" for alias in node.names)
        for node in imports
    )
    assert has_tracing_import, "TracingFactory not imported in server.py"
    print("  ✅ server.py imports TracingFactory")

Also applies to: 597-618

experimental/otel/otel_logging.py (3)

39-41: Duplicated _get_otel_enabled() function across modules.

This function is identical in tracing.py, metrics.py, and otel_logging.py. Consider extracting to a shared config module to follow DRY principles.

♻️ Suggested refactor

Create experimental/otel/config.py:

"""Shared OTEL configuration helpers."""
import os

def get_otel_enabled() -> bool:
    """Check if OTEL is enabled via environment variable."""
    return os.environ.get("OTEL_ENABLED", "false").lower() == "true"

Then import from there in all modules.


49-51: Unused function _get_log_content_enabled().

This function is defined but never called in this file or exported. Either use it in the logging helpers to conditionally include content, or remove it.


246-249: Use f-string conversion flag for clarity.

Per static analysis, use explicit conversion flag instead of str(error).

♻️ Suggested fix
     logger.error(
-        f"Agent error: {agent_name} error_type={err_type} message={str(error)}",
+        f"Agent error: {agent_name} error_type={err_type} message={error!s}",
         exc_info=True,
     )
holmes/core/tracing.py (1)

325-336: Unused type parameter in set_attributes.

The type parameter is accepted but never used. If this is for API compatibility, consider documenting it or logging when it's provided but ignored.

experimental/otel/__init__.py (1)

115-222: Consider sorting __all__ for consistency.

Per static analysis (RUF022), the __all__ list is not sorted. While the current grouping by category is logical and readable, sorting would help with automated tooling and make diffs cleaner when adding new exports.

This is a minor style suggestion - the current categorical organization has its own merits for readability.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 28dd21cd4ee9f45995101ca275ea1d4f8fb0d9ab and 264cb684f12bd8c14f8b1b472d3da767ed9dbfca.

⛔ Files ignored due to path filters (1)
  • poetry.lock is excluded by !**/*.lock
📒 Files selected for processing (15)
  • docs/otel-tracing.md
  • experimental/ag-ui/server-agui.py
  • experimental/otel/__init__.py
  • experimental/otel/attributes.py
  • experimental/otel/metrics.py
  • experimental/otel/otel_logging.py
  • experimental/otel/test_otel.py
  • experimental/otel/test_otel_integration.py
  • experimental/otel/tracing.py
  • holmes/config.py
  • holmes/core/tool_calling_llm.py
  • holmes/core/tracing.py
  • holmes/utils/stream.py
  • pyproject.toml
  • server.py
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Always place Python imports at the top of the file, not inside functions or methods
Use Ruff for formatting and linting with configuration in pyproject.toml
Type hints required (mypy configuration in pyproject.toml)
Pre-commit hooks enforce quality checks on Python files

Files:

  • holmes/config.py
  • experimental/otel/tracing.py
  • experimental/ag-ui/server-agui.py
  • holmes/core/tool_calling_llm.py
  • experimental/otel/test_otel.py
  • experimental/otel/test_otel_integration.py
  • experimental/otel/otel_logging.py
  • server.py
  • holmes/core/tracing.py
  • experimental/otel/__init__.py
  • holmes/utils/stream.py
  • experimental/otel/attributes.py
  • experimental/otel/metrics.py
**/*.{js,ts,tsx,jsx,py,java,cs,go,rb,php}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,ts,tsx,jsx,py,java,cs,go,rb,php}: Use semantic, descriptive names for variables, functions, and components
Write clear, concise comments that explain 'why' rather than 'what'

Files:

  • holmes/config.py
  • experimental/otel/tracing.py
  • experimental/ag-ui/server-agui.py
  • holmes/core/tool_calling_llm.py
  • experimental/otel/test_otel.py
  • experimental/otel/test_otel_integration.py
  • experimental/otel/otel_logging.py
  • server.py
  • holmes/core/tracing.py
  • experimental/otel/__init__.py
  • holmes/utils/stream.py
  • experimental/otel/attributes.py
  • experimental/otel/metrics.py
docs/**/*.md

📄 CodeRabbit inference engine (CLAUDE.md)

Add blank line between header/bold text and a list in MkDocs documentation files, otherwise lists won't render properly

Files:

  • docs/otel-tracing.md
🧠 Learnings (1)
📚 Learning: 2026-01-05T11:14:20.222Z
Learnt from: CR
Repo: HolmesGPT/holmesgpt PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-01-05T11:14:20.222Z
Learning: Applies to holmes/plugins/toolsets/**/*.{py,yaml} : All toolsets MUST return detailed error messages from underlying APIs to enable LLM self-correction, including exact query/command executed, time ranges/parameters/filters used, and full API error response (status code and message)

Applied to files:

  • holmes/core/tool_calling_llm.py
🧬 Code graph analysis (9)
holmes/config.py (1)
holmes/utils/pydantic_utils.py (1)
  • load_model_from_file (39-54)
experimental/otel/tracing.py (2)
experimental/otel/otel_logging.py (2)
  • format (21-36)
  • _get_otel_enabled (39-41)
experimental/otel/metrics.py (2)
  • _get_otel_enabled (31-33)
  • _get_otel_endpoint (41-43)
experimental/ag-ui/server-agui.py (5)
holmes/core/tracing.py (8)
  • TracingFactory (510-599)
  • init_otel (516-532)
  • start_span (117-118)
  • start_span (297-305)
  • start_span (442-447)
  • end (123-124)
  • end (338-340)
  • end (459-462)
experimental/otel/tracing.py (2)
  • get_tracer (409-423)
  • set_span_error (434-445)
experimental/otel/metrics.py (8)
  • init_otel_metrics (46-162)
  • shutdown_otel_metrics (170-180)
  • record_token_usage (186-210)
  • record_operation_duration (213-240)
  • record_tool_duration (243-264)
  • increment_iterations (267-288)
  • increment_tool_calls (291-312)
  • increment_errors (315-336)
holmes/utils/stream.py (1)
  • StreamEvents (17-37)
experimental/otel/attributes.py (1)
  • truncate (159-177)
holmes/core/tool_calling_llm.py (2)
holmes/utils/stream.py (7)
  • build_stream_event_llm_iteration_start (166-182)
  • build_stream_event_llm_iteration_complete (185-219)
  • build_stream_event_tool_invoke_start (222-244)
  • build_stream_event_tool_invoke_end (247-278)
  • build_stream_event_parse_response (281-300)
  • build_stream_event_context_check (303-326)
  • build_stream_event_error_handling (329-351)
holmes/core/truncation/input_context_window_limiter.py (1)
  • limit_input_context_window (147-219)
experimental/otel/test_otel_integration.py (3)
experimental/otel/otel_logging.py (1)
  • format (21-36)
experimental/otel/tracing.py (3)
  • init_otel_tracer (312-406)
  • get_tracer (409-423)
  • set_span_error (434-445)
experimental/otel/attributes.py (1)
  • truncate (159-177)
experimental/otel/otel_logging.py (3)
experimental/otel/tracing.py (1)
  • _get_otel_enabled (98-100)
experimental/otel/metrics.py (1)
  • _get_otel_enabled (31-33)
holmes/plugins/toolsets/logging_utils/logging_api.py (1)
  • logger_name (82-83)
server.py (1)
holmes/core/tracing.py (11)
  • TracingFactory (510-599)
  • SpanType (103-111)
  • init_otel (516-532)
  • create_tracer (568-599)
  • start_trace (145-147)
  • start_trace (197-227)
  • start_trace (391-416)
  • start_trace (487-492)
  • set_attributes (126-129)
  • set_attributes (325-336)
  • set_attributes (454-457)
experimental/otel/__init__.py (4)
experimental/otel/tracing.py (4)
  • init_otel_tracer (312-406)
  • get_tracer (409-423)
  • shutdown_otel_tracer (426-431)
  • set_span_error (434-445)
experimental/otel/attributes.py (1)
  • truncate (159-177)
experimental/otel/metrics.py (9)
  • init_otel_metrics (46-162)
  • get_meter (165-167)
  • shutdown_otel_metrics (170-180)
  • record_token_usage (186-210)
  • record_operation_duration (213-240)
  • record_tool_duration (243-264)
  • increment_iterations (267-288)
  • increment_tool_calls (291-312)
  • increment_errors (315-336)
experimental/otel/otel_logging.py (3)
  • OTELContextFormatter (14-36)
  • setup_otel_logging (54-92)
  • get_otel_logger (95-104)
experimental/otel/metrics.py (2)
experimental/otel/tracing.py (4)
  • export (77-89)
  • _get_otel_enabled (98-100)
  • _get_otel_endpoint (108-110)
  • _create_osis_session (240-309)
experimental/otel/otel_logging.py (1)
  • _get_otel_enabled (39-41)
🪛 LanguageTool
docs/otel-tracing.md

[style] ~266-~266: Consider an alternative verb to strengthen your wording.
Context: ...atically truncated to 8KB. If you still see this error, check for large metadata va...

(IF_YOU_HAVE_THIS_PROBLEM)

🪛 markdownlint-cli2 (0.18.1)
docs/otel-tracing.md

17-17: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


85-85: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


256-256: Emphasis used instead of a heading

(MD036, no-emphasis-as-heading)


260-260: Emphasis used instead of a heading

(MD036, no-emphasis-as-heading)


264-264: Emphasis used instead of a heading

(MD036, no-emphasis-as-heading)

🪛 Ruff (0.14.11)
experimental/otel/tracing.py

155-156: try-except-pass detected, consider logging the exception

(S110)


155-155: Do not catch blind exception: Exception

(BLE001)


173-173: Avoid specifying long messages outside the exception class

(TRY003)


305-305: Consider moving this statement to an else block

(TRY300)


401-401: Consider moving this statement to an else block

(TRY300)


454-454: Do not catch blind exception: Exception

(BLE001)

experimental/ag-ui/server-agui.py

562-562: Use explicit conversion flag

Replace with conversion flag

(RUF010)

experimental/otel/test_otel.py

231-231: Possible hardcoded password assigned to: "METRIC_TOKEN_USAGE"

(S105)


654-654: Do not catch blind exception: Exception

(BLE001)

experimental/otel/test_otel_integration.py

235-235: Abstract raise to an inner function

(TRY301)


235-235: Avoid specifying long messages outside the exception class

(TRY003)


237-237: Do not catch blind exception: Exception

(BLE001)


264-264: Consider moving this statement to an else block

(TRY300)


265-265: Do not catch blind exception: Exception

(BLE001)


453-453: Do not catch blind exception: Exception

(BLE001)


485-485: Do not catch blind exception: Exception

(BLE001)

experimental/otel/otel_logging.py

248-248: Use explicit conversion flag

Replace with conversion flag

(RUF010)

server.py

182-182: Consider moving this statement to an else block

(TRY300)

holmes/core/tracing.py

298-298: Unused method argument: kwargs

(ARG002)


312-312: Unused method argument: kwargs

(ARG002)


328-328: Unused method argument: type

(ARG002)


380-380: Unused method argument: experiment_name

(ARG002)


381-381: Unused method argument: additional_metadata

(ARG002)


529-529: Consider moving this statement to an else block

(TRY300)


530-530: Do not catch blind exception: Exception

(BLE001)

experimental/otel/__init__.py

115-222: __all__ is not sorted

Apply an isort-style sorting to __all__

(RUF022)

experimental/otel/attributes.py

142-142: Possible hardcoded password assigned to: "METRIC_TOKEN_USAGE"

(S105)


150-150: Possible hardcoded password assigned to: "TOKEN_TYPE_INPUT"

(S105)


151-151: Possible hardcoded password assigned to: "TOKEN_TYPE_OUTPUT"

(S105)

experimental/otel/metrics.py

158-158: Consider moving this statement to an else block

(TRY300)


160-160: Do not catch blind exception: Exception

(BLE001)


177-177: Do not catch blind exception: Exception

(BLE001)

🔇 Additional comments (31)
experimental/otel/tracing.py (2)

160-238: LGTM! Robust SigV4 signing implementation.

The AWSSigV4Session class correctly:

  • Handles credential refresh for assumed roles
  • Separates send kwargs from Request kwargs
  • Computes content SHA256 hash for SigV4 signing
  • Masks Authorization header in debug logs

312-407: LGTM! Well-structured initialization with proper lifecycle management.

Good practices observed:

  • Reads env vars at call time, not import time
  • Idempotent initialization (checks _initialized)
  • Registers atexit handler for cleanup
  • Reduced batch size to prevent payload errors

Note: Setting _initialized = True on failure paths (lines 337-338, 342-343, 405) is intentional to prevent repeated initialization attempts, which is a reasonable design choice.

experimental/otel/metrics.py (1)

186-336: LGTM! Well-designed metric recording helpers.

Good practices:

  • Null guards prevent errors when metrics are disabled
  • Consistent attribute naming via otel_attr module
  • Clear docstrings explaining parameters
  • Follows Gen AI semantic conventions
holmes/utils/stream.py (3)

26-37: LGTM - Well-structured event types for OTEL tracing.

The new enum members follow the existing naming convention and provide clear categorization with helpful comments distinguishing LLM iteration events from granular span events.


166-219: LGTM - Clean iteration event builders.

Both build_stream_event_llm_iteration_start and build_stream_event_llm_iteration_complete are well-documented with appropriate optional parameters for finish_reason and cost_usd.


281-351: LGTM - Consistent start/end event pattern.

The is_start pattern for parse_response, context_check, and error_handling events provides a clean API. Lightweight start events and data-rich end events follow good observability practices.

experimental/otel/attributes.py (3)

159-177: LGTM - Correct truncation implementation.

The function properly accounts for the marker length when truncating, ensuring the final output stays within max_size. Handling None by returning an empty string is a sensible default.


142-151: Static analysis false positives - ignore S105 hints.

The S105 warnings for METRIC_TOKEN_USAGE, TOKEN_TYPE_INPUT, and TOKEN_TYPE_OUTPUT are false positives. These are OTEL metric/attribute name constants, not passwords or secrets.


14-31: Note: Using incubating OTEL semantic conventions.

The import from opentelemetry.semconv._incubating.attributes.gen_ai_attributes uses the incubating (unstable) API. Gen AI semantic conventions remain in Development status and may change in future OpenTelemetry releases.

server.py (2)

67-75: LGTM - Clean OTEL initialization with proper error handling.

The init_otel() function follows the pattern requested in PR comments by using TracingFactory.init_otel(). The opt-in via OTEL_ENABLED environment variable is appropriately guarded with logging for both success and failure cases.


177-191: LGTM - Correct exception handling with span error recording.

The middleware properly records error attributes on the span before re-raising exceptions, ensuring errors are traced while preserving the original exception behavior.

experimental/ag-ui/server-agui.py (1)

96-106: LGTM - Proper OTEL initialization via TracingFactory.

The initialization correctly uses TracingFactory.init_otel() as requested in PR comments, making the OTEL integration pluggable. Both tracing and metrics initialization are properly logged.

holmes/core/tool_calling_llm.py (4)

63-69: LGTM - Clean imports for new stream event builders.

The imports are properly organized and follow the existing pattern in the file.


799-830: LGTM - Correct LLM iteration event emission.

Token usage and cost extraction properly handle missing attributes with safe getattr calls and reuse the existing _extract_cost_from_response helper.


833-848: LGTM - Proper error handling event emission.

The error handling events correctly emit start/end pairs with appropriate will_retry flags distinguishing recoverable errors (IncorrectToolCall) from terminal errors (BadRequestError).


897-903: Verify: finish_reasons scope in parse_response_end event.

finish_reasons is assigned inside the try block (line 821). If the code reaches the parse_response_end emission after any error path that doesn't raise, finish_reasons might reference a stale value from a previous iteration or be undefined.

Consider assigning finish_reasons = None before the try block for safety.

experimental/otel/test_otel_integration.py (2)

307-323: Previous review feedback addressed.

The overly broad wildcard query has been removed as noted in the comment on line 308. The query now correctly relies on specific match clauses for the test_id.


461-514: Well-structured test orchestration.

The main() function properly checks environment, runs tests sequentially, handles failures gracefully, and provides clear summary output. The OpenSearch verification being non-blocking (line 495-498) is a good choice given indexing delays.

experimental/otel/test_otel.py (2)

82-114: Thorough test coverage for truncate function.

Good edge case coverage including None, empty string, exact limit, and over limit scenarios.


547-568: Good verification of SpanType to OTEL mappings.

The test validates that all SpanTypes have OTEL mappings and that specific mappings follow Gen AI semantic conventions.

experimental/otel/otel_logging.py (2)

54-92: Well-designed logger setup with OTEL context.

Good implementation that:

  • Avoids duplicate handlers
  • Conditionally adds trace context based on OTEL enablement
  • Falls back to standard formatting when OTEL is disabled

26-34: No changes needed. The code correctly uses ctx.trace_flags.sampled, which is the proper OpenTelemetry Python API for accessing the sampled flag from a SpanContext. No inconsistency exists.

Likely an incorrect or invalid review comment.

holmes/core/tracing.py (8)

278-286: Good mapping of SpanTypes to Gen AI semantic conventions.

The SPAN_TYPE_TO_OTEL mapping properly aligns internal span types with OpenTelemetry Gen AI semantic conventions, enabling consistent observability across backends.


297-305: Unused **kwargs parameter is acceptable for API compatibility.

The kwargs in start_span maintains interface compatibility with Braintrust spans that may pass additional parameters. This is a reasonable design choice.


345-350: Good error handling in context manager exit.

Properly records errors on the span before ending when an exception occurs. The import inside the method keeps the dependency lazy.


361-368: Lazy initialization is a good pattern.

The _ensure_initialized() method defers OTEL setup until first use, which is efficient for cases where tracing may not be needed.


467-469: CompositeSpan.exit handles exceptions differently than OTELSpan.

OTELSpan.__exit__ calls set_span_error when exc_val is present, but CompositeSpan.__exit__ delegates to each span's __exit__. This is correct since each underlying span handles its own error recording.


524-532: Broad exception catch with appropriate fallback.

While static analysis flags this as BLE001, catching all exceptions during initialization is reasonable here since we want tracing to be non-blocking. The warning log and False return provide visibility into failures.


556-562: Good check for OTEL enablement before creating tracer.

The explicit check for OTEL_ENABLED=true with a warning message provides clear feedback when OTEL is requested but not configured.


586-597: Well-designed composite tracer creation.

The logic correctly:

  1. Creates tracers for each type
  2. Filters out DummyTracers
  3. Returns single tracer if only one is active
  4. Returns CompositeTracer only when multiple tracers are active

This addresses the PR objective of making OTEL pluggable alongside Braintrust.

experimental/otel/__init__.py (1)

1-113: Well-organized public API surface.

The re-exports are logically grouped by category (tracing, attributes, metrics, logging) with clear comments. This provides a clean, centralized import point for consumers.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment thread experimental/ag-ui/server-agui.py Outdated
Comment thread experimental/ag-ui/server-agui.py
Comment thread experimental/otel/metrics.py
Comment thread holmes/core/tool_calling_llm.py
Comment thread pyproject.toml Outdated
@goyamegh
goyamegh force-pushed the feat/otel-tracing-agui branch from 264cb68 to af146b0 Compare January 15, 2026 01:27
Comment thread docs/otel-tracing.md Outdated

## AWS OSIS Integration

For AWS OpenSearch Ingestion Service (OSIS), the implementation:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Comment thread experimental/ag-ui/server-agui.py
Comment thread experimental/otel/attributes.py Outdated
def truncate(value: Optional[str], max_size: int = MAX_ATTRIBUTE_SIZE) -> str:
"""Truncate a string value to prevent OTEL payload size errors.

Based on ml-commons AgentTracer.truncate() pattern.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: ml-commons is not widely known. Please permalink to AgentTracer.truncate() method in ml-commons repo.

Comment thread experimental/otel/tracing.py Outdated
Comment on lines +354 to +364
# Create OTLP HTTP exporter with AWS SigV4 auth for OSIS
# Endpoint should be like: https://your-osis-pipeline/v1/traces
osis_session = _create_osis_session(otel_endpoint)
if osis_session:
exporter = OTLPSpanExporter(endpoint=otel_endpoint, session=osis_session)
else:
# Fall back to unauthenticated exporter (may fail with OSIS)
logging.warning(
"OSIS session creation failed, using unauthenticated exporter"
)
exporter = OTLPSpanExporter(endpoint=otel_endpoint)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we consolidate how we're detecting unauthenticated OTLP endpoint versus AWS (OSIS) across metrics/logs/traces? Seems metrics has different logic: https://github.com/goyamegh/holmesgpt/blob/af146b07c4014b90459228b0bb2a44372a73db5f/experimental/otel/metrics.py#L79-L81

Idea: Centralize logic in otel/__init__.py instead

@@ -0,0 +1,250 @@
"""OpenTelemetry correlated logging for HolmesGPT.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  1. Are these logs written to OTLP endpoint like metrics and traces?
  2. Should we name this file logging.py for consistency with metrics.py and tracing.py?

Comment thread experimental/otel/__init__.py Outdated
@@ -0,0 +1,222 @@
"""OpenTelemetry instrumentation for HolmesGPT experimental endpoints."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since the tracing implementation is already integrated into core/, should we consider moving this out of experimental/?. My understanding is that experimental code should be removable without impacting the core codebase. What do you think about refactoring this to make OTel/OTLP export purely optional through feature flags (env vars)? This way, when the flags are disabled, no experimental code paths would execute.

Comment thread server.py
Comment on lines +159 to +160
@app.middleware("http")
async def otel_tracing_middleware(request: Request, call_next):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice usage of middleware here! Are metrics/logs boilerplate already handled elsewhere?

Comment thread holmes/config.py Outdated
config_file = Path(DEFAULT_CONFIG_LOCATION)
config_from_file: Optional[Config] = None
if config_file.exists():
logging.debug(f"Loading config from {config_file}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Since this is only logged once, consider INFO log-level.

@kylehounslow

Copy link
Copy Markdown
Contributor

nit: please update PR title to reflect latest changes. Perhaps just feat: Add OpenTelemetry tracing

@goyamegh
goyamegh force-pushed the feat/otel-tracing-agui branch from af146b0 to aabfcf4 Compare February 12, 2026 07:28

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 12

Caution

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

⚠️ Outside diff range comments (1)
holmes/core/tool_calling_llm.py (1)

1041-1068: ⚠️ Potential issue | 🟡 Minor

Bug: parse_response_start emitted without matching parse_response_end on incorrect_tool_call path.

When incorrect_tool_call is true (Line 1052), the continue on Line 1068 jumps back to the top of the while loop. The PARSE_RESPONSE_START event at Line 1042 is emitted but the matching PARSE_RESPONSE_END at Line 1089 is skipped. This creates an unpaired start event in the stream, which will confuse any OTEL consumer that expects matched start/end pairs.

🐛 Proposed fix: emit parse_response_end before continuing
                 if incorrect_tool_call:
                     logging.warning(
                         "Detected incorrect tool call. Structured output will be disabled. This can happen on models that do not support tool calling. For Azure AI, make sure the model name contains 'gpt-4.1' or other structured output compatible models. To disable this holmes behaviour, set REQUEST_STRUCTURED_OUTPUT_FROM_LLM to `false`."
                     )
                     # Emit error handling event for structured output fallback
                     yield build_stream_event_error_handling(
                         is_start=True,
                         error_type="IncorrectToolCall",
                         error_message="Detected incorrect tool call, disabling structured output",
                         will_retry=True,
                     )
                     yield build_stream_event_error_handling(is_start=False)
+                    # Close the parse_response span before retrying
+                    yield build_stream_event_parse_response(
+                        is_start=False,
+                        tool_call_count=0,
+                        finish_reason="retry_structured_output",
+                    )
                     # disable structured output going forward and and retry
                     sentry_helper.capture_structured_output_incorrect_tool_call()
                     response_format = None
                     max_steps = max_steps + 1
                     continue
🤖 Fix all issues with AI agents
In `@docs/otel-tracing.md`:
- Around line 85-103: Update the documentation examples so the LLM span names
use an Anthropic Claude model instead of "gpt-4": replace occurrences of the
span string "chat gpt-4" (seen in the example hierarchy and the "chat {model}"
naming examples) with the Anthropic model identifier (e.g., "chat
anthropic/claude-sonnet-4-5-20250929"), keeping the surrounding span formats
like "invoke_agent HolmesGPT" and "execute_tool {tool_name}" unchanged.

In `@experimental/ag-ui/server-agui.py`:
- Around line 578-589: The span attributes are using metric instrument constants
(otel_attr.METRIC_AGENT_TOOL_CALLS, otel_attr.METRIC_AGENT_ITERATIONS) instead
of the intended span attribute keys; update the calls to root_span.set_attribute
to use the attribute constants otel_attr.TOOL_CALL_COUNT and
otel_attr.AGENT_ITERATION respectively, leaving the other attributes
(INPUT_TOKENS, OUTPUT_TOKENS, TOTAL_TOKENS, COST_USD, RESULT_SUCCESS) unchanged
so all final metrics are recorded with the correct otel_attr keys.
- Around line 22-40: Unconditionally importing OpenTelemetry and calling
initialization causes OTEL to run even when disabled; move the OTEL-related
imports (get_tracer, set_span_error, experimental.otel.attributes as otel_attr,
and metrics functions like init_otel_metrics, record_token_usage,
record_operation_duration, record_tool_duration, increment_iterations,
increment_tool_calls, increment_errors) into a guarded try/except block and only
perform initialization calls (TracingFactory.init_otel() and
init_otel_metrics()) when OTEL is enabled (check OTEL_ENABLED == "true"); ensure
any failures fall back cleanly by catching ImportError/Exception and leaving
tracing/metrics references safe for the rest of the module (e.g., set
tracer-related names to None or no-op placeholders if imports fail).

In `@experimental/otel/attributes.py`:
- Around line 14-31: The import from
opentelemetry.semconv._incubating.gen_ai_attributes (e.g., GEN_AI_AGENT_NAME,
GEN_AI_REQUEST_MODEL, GenAiOperationNameValues) relies on an unstable incubating
API; either explicitly pin opentelemetry-semantic-conventions in pyproject.toml
to a specific safe version (e.g., opentelemetry-semantic-conventions = "0.X.Y")
to freeze the transitive API, or copy the needed constants locally into this
module and stop importing from _incubating; at minimum add a module docstring at
the top of experimental/otel/attributes.py stating the dependency on the
incubating namespace and that updates may be required when upstream changes
occur so reviewers know the risk.

In `@experimental/otel/otel_logging.py`:
- Around line 138-143: The current condition skips logging a legitimate zero
cost because it uses a truthy check; in otel_logging.py update the conditional
that appends cost to msg_parts to check for None explicitly (use "cost_usd is
not None" instead of "if cost_usd") so zero values are included, leaving the
existing formatting logic (f"cost=${cost_usd:.6f}") and other parts like
finish_reason/msg_parts unchanged.

In `@experimental/otel/tracing.py`:
- Around line 268-270: The double dict unpacking in the call
super().request(method, url, **kwargs, **send_kwargs) can raise TypeError if
keys overlap; change it to build a single kwargs dict and check/resolve
conflicts before unpacking. For example, create merged = kwargs.copy(), check
for intersection between merged and send_kwargs (raise or decide precedence),
then merged.update(send_kwargs) and call super().request(method, url, **merged)
so that overlapping keys are handled safely; reference the request method where
super().request is called in experimental/otel/tracing.py.
- Around line 14-16: The top-level boto3/botocore imports create an unnecessary
hard dependency; change to lazy imports by removing boto3 and botocore imports
from the module top and instead import them inside needs_aws_auth(), inside
_create_osis_session(), and inside the AWSSigV4Session implementation where they
are used; specifically import boto3, from botocore.auth import SigV4Auth and
from botocore.awsrequest import AWSRequest (or the specific symbols used) within
those functions/classes so environments that don't need AWS SigV4 auth won’t
require boto3/botocore installed.
- Around line 80-104: LoggingSpanExporter must subclass the SpanExporter base so
isinstance checks in BatchSpanProcessor succeed; change the class declaration to
inherit from SpanExporter (e.g. class LoggingSpanExporter(SpanExporter):) and
update the constructor type for wrapped_exporter from OTLPSpanExporter to
SpanExporter so the wrapper accepts any exporter implementing the base; keep the
existing export, shutdown and force_flush method implementations unchanged so
they satisfy the SpanExporter contract.

In `@holmes/core/tracing.py`:
- Around line 36-46: The fallback assigns truncate and set_span_error to None
which causes TypeError when OTELSpan.log and OTELSpan.__exit__ call them; fix by
providing safe no-op fallbacks or by guarding calls. Update the except
ImportError block to set truncate and set_span_error to small no-op functions
with the same signatures as the real implementations (so OTELSpan.log and
OTELSpan.__exit__ can call them safely), or alternatively add conditional checks
in OTELSpan.log and OTELSpan.__exit__ to only call truncate and set_span_error
when OTEL_EXPERIMENTAL_AVAILABLE (or when the function is not None); ensure
get_tracer/init_otel_tracer are similarly safe or guarded.
- Around line 523-538: TracingFactory.init_otel currently calls
init_otel_tracer() unconditionally which will raise TypeError when
init_otel_tracer is None; modify init_otel to first check the
OTEL_EXPERIMENTAL_AVAILABLE flag (and/or that init_otel_tracer is callable) and
if not available log a clear warning and return False, otherwise call
init_otel_tracer(), set TracingFactory._otel_initialized from the result, and
preserve the existing exception handling around the call.
- Around line 369-374: OTELTracer._ensure_initialized currently calls
init_otel_tracer() and get_tracer() unconditionally; add a defensive guard that
checks the OTEL availability (either OTEL_EXPERIMENTAL_AVAILABLE or that
init_otel_tracer and get_tracer are not None/callable) before calling them, and
if unavailable raise a clear RuntimeError (or return without initializing)
explaining OTEL is not installed/enabled; update OTELTracer._ensure_initialized
to validate init_otel_tracer and get_tracer before invocation and only set
_initialized and _native_tracer after successful calls.
- Line 11: The unconditional "from opentelemetry import trace as otel_trace"
makes OpenTelemetry a hard dependency; wrap that import in a try/except and set
a module-level flag (e.g., OTEL_API_AVAILABLE = True/False) so the import is
optional, then only define or instantiate OTEL-specific classes (OTELSpan,
OTELTracer) when OTEL_API_AVAILABLE is True; ensure DummySpan remains always
available for tool_calling_llm.py and update any code paths referencing
otel_trace/OTELTracer to check OTEL_API_AVAILABLE before using them.
🧹 Nitpick comments (14)
experimental/otel/otel_logging.py (2)

39-51: Consider extracting shared _get_otel_enabled() into a common location.

_get_otel_enabled() is identically defined in experimental/otel/tracing.py (line 107), experimental/otel/metrics.py (line 30), and here (line 39). Similarly, _get_logs_enabled and _get_log_content_enabled are defined but never used in this file.


246-249: Use explicit !s conversion flag per Ruff RUF010.

-        f"Agent error: {agent_name} error_type={err_type} message={str(error)}",
+        f"Agent error: {agent_name} error_type={err_type} message={error!s}",
server.py (2)

236-268: Tracer created per request; consider creating it once at module level.

TracingFactory.create_tracer("otel") is called inside the middleware on every request. This is unnecessary overhead — create the tracer once and reuse it.

Also, http.status_code should be an integer per OTEL HTTP semantic conventions, not a string.

Proposed fix
+# Create tracer once at module level
+_otel_tracer = TracingFactory.create_tracer("otel") if otel_enabled else None
+
 if otel_enabled:
 
     `@app.middleware`("http")
     async def otel_tracing_middleware(request: Request, call_next):
         """Create OTEL spans for incoming API requests."""
         if request.url.path in ("/healthz", "/readyz", "/docs", "/openapi.json"):
             return await call_next(request)
 
-        tracer = TracingFactory.create_tracer("otel")
+        tracer = _otel_tracer
         span_name = f"{request.method} {request.url.path}"
 
         with tracer.start_trace(span_name, SpanType.TASK) as span:
             span.set_attributes(
                 span_attributes={
                     "http.method": request.method,
                     "http.url": str(request.url),
                     "http.route": request.url.path,
                 }
             )
             try:
                 response = await call_next(request)
                 span.set_attributes(
-                    span_attributes={"http.status_code": str(response.status_code)}
+                    span_attributes={"http.status_code": response.status_code}
                 )
                 return response

66-90: Missing return type annotation on init_otel().

As per coding guidelines, type hints are required for Python files.

Proposed fix
-def init_otel():
+def init_otel() -> bool:
docs/otel-tracing.md (1)

17-52: Add language specifiers to fenced code blocks.

The architecture diagram code blocks (lines 17 and 85) lack language specifiers, flagged by markdownlint (MD040). Use text or plaintext for ASCII diagrams.

-```
+```text
 ┌─────────────────────────────────────────────────────────────────┐
experimental/ag-ui/server-agui.py (1)

619-627: Minor Ruff findings in exception cleanup block.

Line 621: loop variable key is unused (Ruff B007) — rename to _key. Line 649: use {e!s} instead of {str(e)} (Ruff RUF010).

Proposed fix
-            for key, span in list(active_spans.items()):
+            for _key, span in list(active_spans.items()):
                 try:
                     set_span_error(span, e)
                     span.end()
-                except Exception:
-                    pass  # Best effort cleanup
+                except Exception:  # noqa: BLE001
+                    pass  # Best effort span cleanup
-                    message=f"Agent encountered an error: {str(e)}",
+                    message=f"Agent encountered an error: {e!s}",
experimental/otel/test_otel_integration.py (2)

1-28: Integration test is a standalone script rather than a pytest test.

This test can't be discovered by pytest and manipulates private module state (tracing._initialized, tracing._tracer_provider). The docstring notes it "can be converted to pytest later" — consider creating a tracking issue for that migration.


111-115: Resetting private module state is fragile.

Directly mutating tracing._initialized and tracing._tracer_provider couples tests to internal implementation details. If the module internals change, these tests break silently. This is acceptable for a quick verification script but should be addressed if migrated to pytest (e.g., via a proper reset() function or fixture).

experimental/otel/test_otel.py (1)

571-617: File-content string matching tests are brittle and will break on any refactor.

test_server_uses_tracing_factory and test_agui_uses_tracing_factory open source files and assert on exact string patterns like 'TracingFactory.create_tracer("otel")'. These break on any formatting change, import aliasing, or code reorganization — without indicating a real regression. Consider replacing with import-based tests that verify the actual runtime behavior instead.

experimental/otel/tracing.py (1)

345-444: _initialized = True on failure prevents any recovery.

Lines 370–376 and 443 set _initialized = True even when initialization fails or OTEL is disabled. This means if env vars are corrected after a failed first attempt, a restart is required. The warning comment at line 288-291 acknowledges thread-safety concerns but not this "no retry" behavior — consider documenting it in the function docstring.

holmes/core/tool_calling_llm.py (2)

1154-1178: duration_ms is hardcoded to 0, making the metric meaningless.

The TOOL_INVOKE_END event always reports duration_ms=0. The start event is emitted before the executor .submit(), and the end event is emitted after future.result(), so the timing information is actually available — you'd just need to capture time.monotonic() around the tool invocation.

Also, APPROVAL_REQUIRED status is mapped to "FAILURE", which could be misleading in dashboards. Consider a distinct status value.

♻️ Sketch: capture actual duration and distinguish approval status
+import time
+
 # ... inside the for loop over futures ...
+                    # Track tool start time (or capture per-future before submit)
                     # Emit tool invoke end event for OTEL tracing
                     tool_status = (
                         "SUCCESS"
                         if tool_call_result.result.status
                         == StructuredToolResultStatus.SUCCESS
-                        else "FAILURE"
+                        elif tool_call_result.result.status
+                        == StructuredToolResultStatus.APPROVAL_REQUIRED
+                        else "APPROVAL_REQUIRED"
+                        else "FAILURE"
                     )

For duration, consider recording time.monotonic() before executor.submit() per-future, then computing elapsed in the completion loop.


1024-1031: Error events are emitted before determining the error type.

Both error_handling_start and error_handling_end events are emitted at Lines 1025–1031 before the if on Line 1032 checks whether this is the specific Azure error or a generic re-raise. The will_retry=False is set unconditionally, but only the Azure-specific case gets a custom message; the generic re-raise may benefit from different handling. This is a minor concern since both paths ultimately raise, but it slightly reduces the diagnostic value of the events.

experimental/otel/metrics.py (1)

76-99: Imports inside function body violate coding guidelines.

The coding guidelines state: "ALWAYS place Python imports at the top of the file, not inside functions or methods." While the dynamic import pattern is pragmatic for optional dependencies, it conflicts with the explicit guideline.

Additionally, _create_osis_session (Line 85) is a private function being used across module boundaries. Consider exposing it as a public function or creating a shared utility.

As per coding guidelines: **/*.py: ALWAYS place Python imports at the top of the file, not inside functions or methods.

♻️ Move imports to module top and make cross-module function public

Move the conditional imports to the top of the file with a try/except guard:

+try:
+    from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
+        OTLPMetricExporter,
+    )
+    from experimental.otel.tracing import needs_aws_auth, create_osis_session
+except ImportError:
+    OTLPMetricExporter = None  # type: ignore
+    needs_aws_auth = None  # type: ignore
+    create_osis_session = None  # type: ignore

And in experimental/otel/tracing.py, rename _create_osis_session → create_osis_session to reflect its cross-module usage.

holmes/utils/stream.py (1)

237-241: Magic number 8192 duplicated — consider using a shared constant.

The truncation limit 8192 appears in build_stream_event_tool_invoke_start (Line 240), build_stream_event_tool_invoke_end (Line 273), and matches MAX_ATTRIBUTE_SIZE in experimental/otel/attributes.py. Using a shared constant would prevent drift if the limit changes.

Also applies to: 272-273

Comment thread docs/otel-tracing.md
Comment thread experimental/ag-ui/server-agui.py Outdated
Comment thread experimental/ag-ui/server-agui.py
Comment on lines +14 to +31
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import (
GEN_AI_AGENT_NAME,
GEN_AI_CONVERSATION_ID,
GEN_AI_OPERATION_NAME,
GEN_AI_PROVIDER_NAME,
GEN_AI_REQUEST_MAX_TOKENS,
GEN_AI_REQUEST_MODEL,
GEN_AI_REQUEST_TEMPERATURE,
GEN_AI_RESPONSE_FINISH_REASONS,
GEN_AI_RESPONSE_MODEL,
GEN_AI_SYSTEM,
GEN_AI_TOKEN_TYPE,
GEN_AI_TOOL_CALL_ID,
GEN_AI_TOOL_NAME,
GEN_AI_USAGE_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS,
GenAiOperationNameValues,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

opentelemetry-semantic-conventions incubating namespace stability

💡 Result:

In OpenTelemetry Semantic Conventions, “incubating” denotes unstable/preview conventions that do not carry stability guarantees and may change (including breaking changes) without a major-version bump.

  • JavaScript package behavior: @opentelemetry/semantic-conventions exposes only stable conventions and follows SemVer (no breaking changes except major versions). The separate entry point @opentelemetry/semantic-conventions/incubating contains unstable (“experimental”) conventions and may include breaking changes in minor releases. Instrumentations are advised not to rely on incubating exports at runtime (copy constants locally or otherwise protect against churn). [1]
  • Generated semconv artifacts / namespaces: the spec’s code-generation guidance explicitly separates artifacts and namespaces:
    • stable: opentelemetry-semconv with namespace opentelemetry.semconv
    • incubating: opentelemetry-semconv-incubating with namespace opentelemetry.semconv.incubating (containing stable + unstable) [2]
  • Practical implication: anything under an incubating namespace/entry point should be treated as non-stable, meaning attribute/metric/event names and group definitions can be revised as part of the stabilization process. (The spec separately defines “group stability” levels like development/alpha/beta/.../stable and emphasizes that stable instrumentations shouldn’t emit unstable parts by default.) [3]

Sources:
[1] OpenTelemetry JS semconv docs (incubating entry point + stability/SemVer notes) (open-telemetry.github.io)
[2] OpenTelemetry semconv code-generation guidance (artifact + namespace names) (opentelemetry.io)
[3] OpenTelemetry semantic convention groups spec (stability levels + rules) (opentelemetry.io)

Citations:


🏁 Script executed:

# Find and examine the project's pyproject.toml or requirements files
find . -name "pyproject.toml" -o -name "requirements*.txt" -o -name "setup.py" | head -20

Repository: HolmesGPT/holmesgpt

Length of output: 662


🏁 Script executed:

# Check the current experimental/otel/attributes.py file structure
head -35 experimental/otel/attributes.py

Repository: HolmesGPT/holmesgpt

Length of output: 1203


🏁 Script executed:

# Examine pyproject.toml for opentelemetry-semantic-conventions version constraint
cat pyproject.toml | grep -A 5 -B 5 "opentelemetry-semantic-conventions"

Repository: HolmesGPT/holmesgpt

Length of output: 45


🏁 Script executed:

# Check if there are any comments or documentation about this incubating dependency
rg -i "incubating|unstable|semconv" experimental/otel/ -A 2 -B 2

Repository: HolmesGPT/holmesgpt

Length of output: 1037


🏁 Script executed:

# Read the full pyproject.toml to see all dependencies
cat pyproject.toml | head -100

Repository: HolmesGPT/holmesgpt

Length of output: 2532


🏁 Script executed:

# Search for opentelemetry in all dependency files
rg "opentelemetry" pyproject.toml

Repository: HolmesGPT/holmesgpt

Length of output: 173


Pin opentelemetry-semantic-conventions version and document the incubating dependency risk.

The opentelemetry.semconv._incubating namespace is not covered by stability guarantees and can introduce breaking changes in minor version releases (not just major versions). Currently, opentelemetry-semantic-conventions is not pinned explicitly in pyproject.toml (it's a transitive dependency via other OpenTelemetry packages). Either:

  1. Add an explicit, tightly pinned version constraint (e.g., opentelemetry-semantic-conventions = "0.X.Y") to guard against unexpected API changes, or
  2. Copy the gen_ai_attributes constants locally to insulate this code from upstream churn, or
  3. Add clear documentation in the module docstring noting that this code depends on an unstable API and may require updates when the semconv spec stabilizes.
🤖 Prompt for AI Agents
In `@experimental/otel/attributes.py` around lines 14 - 31, The import from
opentelemetry.semconv._incubating.gen_ai_attributes (e.g., GEN_AI_AGENT_NAME,
GEN_AI_REQUEST_MODEL, GenAiOperationNameValues) relies on an unstable incubating
API; either explicitly pin opentelemetry-semantic-conventions in pyproject.toml
to a specific safe version (e.g., opentelemetry-semantic-conventions = "0.X.Y")
to freeze the transitive API, or copy the needed constants locally into this
module and stop importing from _incubating; at minimum add a module docstring at
the top of experimental/otel/attributes.py stating the dependency on the
incubating namespace and that updates may be required when upstream changes
occur so reviewers know the risk.

Comment thread experimental/otel/otel_logging.py
Comment thread experimental/otel/tracing.py Outdated
Comment thread holmes/core/tracing.py Outdated
Comment thread holmes/core/tracing.py
Comment thread holmes/core/tracing.py
Comment thread holmes/core/tracing.py
Comment thread docs/otel-tracing.md
Comment on lines +56 to +66
### Environment Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `OTEL_ENABLED` | Enable OTEL tracing (`true`/`false`) | `false` |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP endpoint URL | Required |
| `OTEL_AWS_PROFILE` | AWS profile for OSIS authentication | None |
| `OTEL_AWS_REGION` | AWS region for OSIS (auto-detected from endpoint) | Auto |
| `OTEL_METRICS_ENABLED` | Enable OTEL metrics | `true` |
| `OTEL_DEBUG` | Enable debug logging for span lifecycle | `false` |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Make sure these are aligned with OpenTelemetry official environment variables:

https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/

Suggested change
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `OTEL_ENABLED` | Enable OTEL tracing (`true`/`false`) | `false` |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP endpoint URL | Required |
| `OTEL_AWS_PROFILE` | AWS profile for OSIS authentication | None |
| `OTEL_AWS_REGION` | AWS region for OSIS (auto-detected from endpoint) | Auto |
| `OTEL_METRICS_ENABLED` | Enable OTEL metrics | `true` |
| `OTEL_DEBUG` | Enable debug logging for span lifecycle | `false` |
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `OTEL_SDK_DISABLED` | Disable OTEL tracing (`true`/`false`) | `true` |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP endpoint URL | Required |
| `OTEL_RESOURCE_ATTRIBUTES` | Key-value pairs to be used as resource attributes, eg AWS profile and region for OSIS | None |
| `OTEL_METRICS_EXPORTER` | Metrics exporter to be used, set to `none` to disable metrics | `true` |
| `OTEL_LOG_LEVEL` | Set to `debug` to enable debug logging for span lifecycle | `info` |

Note, that we (OpenTelemetry) are currently working on a configuration file that enables much richer and broader options.

Comment thread docs/otel-tracing.md Outdated
Comment on lines +74 to +79
# AWS OSIS (OpenSearch Ingestion Service)
export OTEL_ENABLED=true
export OTEL_EXPORTER_OTLP_ENDPOINT=https://pipeline-id.us-east-1.osis.amazonaws.com/otel-trace/v1/traces
export OTEL_AWS_PROFILE=your-aws-profile
export OTEL_AWS_REGION=us-east-1
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not sure if those OTEL_AWS are setting resource attributes as I have assumed above or are something specific to OSIS, if this is the case I would recommend to name them AWS_OSIS_... or OSIS_AWS_... but not put them under OTEL_

@goyamegh
goyamegh force-pushed the feat/otel-tracing-agui branch from aabfcf4 to 4f517ce Compare February 18, 2026 13:22

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🧹 Nitpick comments (11)
tests/test_bash_session_prefix_flow.py (1)

43-49: LGTM — minor: mock_response.usage = MagicMock() reassignment is redundant

The finish_reason ternary is correct ("stop" / "tool_calls" matches the OpenAI spec), and the token counts are arithmetically consistent. The hardcoded values are appropriate for a test fixture.

One small nit: since mock_response is already a MagicMock(), mock_response.usage is already an auto-generated MagicMock() child. Reassigning it to a new MagicMock() on line 46 before setting attributes on lines 47–49 has no net effect and can be dropped.

🧹 Proposed simplification
-    # Set usage to avoid MagicMock serialization issues
-    mock_response.usage = MagicMock()
-    mock_response.usage.prompt_tokens = 100
-    mock_response.usage.completion_tokens = 50
-    mock_response.usage.total_tokens = 150
+    # Set usage fields explicitly to avoid MagicMock serialization issues in OTEL tracing
+    mock_response.usage.prompt_tokens = 100
+    mock_response.usage.completion_tokens = 50
+    mock_response.usage.total_tokens = 150
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_bash_session_prefix_flow.py` around lines 43 - 49, Remove the
redundant reassignment of mock_response.usage to a new MagicMock: since
mock_response is already a MagicMock, its .usage child is auto-created, so
delete the line "mock_response.usage = MagicMock()" and leave the subsequent
assignments (mock_response.usage.prompt_tokens, .completion_tokens,
.total_tokens) as-is so the test still sets the token counts while avoiding an
unnecessary override of the existing mock object.
holmes/config.py (1)

191-246: Align load_from_file to use Pydantic v2's model_dump() for consistency.

load_from_env now correctly uses Pydantic v2's model_dump() (line 244), while the adjacent load_from_file method still calls the deprecated .dict() (line 182). Both methods share the same "dump → merge → reconstruct" pattern, so they should use the same API. In Pydantic v2, .dict() is deprecated and emits a deprecation warning; it now simply delegates to model_dump().

♻️ Align `load_from_file` to use `model_dump()`
-            merged_config = config_from_file.dict()
+            merged_config = config_from_file.model_dump()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/config.py` around lines 191 - 246, The load_from_file method currently
uses the deprecated Pydantic v2 .dict() call when reconstructing the Config
instance; update load_from_file to call model_dump() instead (mirror the pattern
used in load_from_env), so replace the .dict() usage in load_from_file with
model_dump() when creating merged_config and then pass that to Config(...) or
cls(...); reference the load_from_file function and the merged_config variable
to locate the change.
experimental/otel/otel_logging.py (2)

249-252: Use f-string conversion flag instead of explicit str() call.

Ruff RUF010 flags str(error) inside f-strings. Use the !s conversion flag for clarity and consistency.

Proposed fix
     err_type = error_type or type(error).__name__
     logger.error(
-        f"Agent error: {agent_name} error_type={err_type} message={str(error)}",
+        f"Agent error: {agent_name} error_type={err_type} message={error!s}",
         exc_info=True,
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@experimental/otel/otel_logging.py` around lines 249 - 252, Replace the
explicit str() call inside the f-string in the logger.error call so it uses the
f-string conversion flag; in the block where err_type is set (err_type =
error_type or type(error).__name__) and logger.error is invoked, change the
message to use {error!s} (e.g., "Agent error: {agent_name} error_type={err_type}
message={error!s}") and keep exc_info=True unchanged so Ruff RUF010 is satisfied
and formatting remains consistent.

39-54: Remove unused private helper functions _get_logs_enabled and _get_log_content_enabled.

These functions are defined but never called anywhere in the codebase. Since they are private (underscore-prefixed), they're not part of the public API. Remove them or document them as placeholders for future use if intentional.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@experimental/otel/otel_logging.py` around lines 39 - 54, Remove the two
unused private helper functions _get_logs_enabled and _get_log_content_enabled
from experimental/otel/otel_logging.py (they are defined but never called); if
you intended to keep them as placeholders instead, add a short docstring comment
above each explaining they are intentionally unused and add a # noqa or explicit
usage comment to silence linters—otherwise simply delete the function
definitions and any related unused imports or references.
holmes/utils/stream.py (2)

237-241: Hardcoded truncation limit 8192 duplicates MAX_ATTRIBUTE_SIZE from attributes.py.

The magic number 8192 appears in multiple places (lines 240, 273) and is also defined as MAX_ATTRIBUTE_SIZE in experimental/otel/attributes.py. While importing from the experimental module might not be desirable here (to avoid coupling core utils to experimental code), consider defining a local constant at the top of this file.

Proposed fix
+# Maximum size for string payloads in stream events (matches OTEL attribute limits)
+_MAX_PAYLOAD_SIZE = 8192
+
+
 def build_stream_event_tool_invoke_start(
     tool_name: str,
     tool_call_id: str,
     tool_arguments: Optional[str] = None,
 ) -> StreamMessage:
     ...
     if tool_arguments is not None:
-        data["tool_arguments"] = (
-            tool_arguments[:8192] if len(tool_arguments) > 8192 else tool_arguments
-        )
+        data["tool_arguments"] = tool_arguments[:_MAX_PAYLOAD_SIZE] if len(tool_arguments) > _MAX_PAYLOAD_SIZE else tool_arguments

Also applies to: 272-273

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/utils/stream.py` around lines 237 - 241, The hardcoded truncation
value 8192 is duplicated and should be replaced by a named constant; add a local
constant (e.g., MAX_ATTR_SIZE or LOCAL_MAX_ATTRIBUTE_SIZE) near the top of
holmes/utils/stream.py and use it wherever tool_arguments
(data["tool_arguments"]) is truncated (and the similar truncation at lines
handling other attributes around 272-273) so the truncation uses that constant
instead of the magic number; update occurrences of the literal 8192 to reference
the new constant (keep the same numeric value).

325-325: Minor inconsistency: data = {} lacks type annotation while similar variables on lines 299, 355 are annotated as dict[str, Any].

For consistency across the builder functions:

Proposed fix
-    data = {}
+    data: dict[str, Any] = {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/utils/stream.py` at line 325, The variable "data" in
holmes/utils/stream.py is declared as data = {} without a type annotation while
similar builder variables use dict[str, Any]; update the declaration of data
(the local variable in the builder function where data is set) to include an
explicit type hint (data: dict[str, Any] = {}) so it matches the other annotated
variables (see the other builder variables declared as dict[str, Any]) and keeps
typing consistent across the function(s) using data.
holmes/core/tracing.py (2)

343-359: OTELSpan.set_attributes converts None values to empty strings, which may mask missing data.

Line 355 sets None attribute values as empty strings (""). In OTEL, it's generally better to omit attributes entirely rather than set them to empty strings, as this can confuse downstream queries (distinguishing "not set" from "explicitly empty").

Proposed fix
         if span_attributes:
             for key, value in span_attributes.items():
-                if value is None:
-                    self._span.set_attribute(key, "")
-                elif isinstance(value, (str, int, float, bool)):
+                if value is None:
+                    continue  # Omit unset attributes
+                elif isinstance(value, (str, int, float, bool)):
                     self._span.set_attribute(key, value)
                 else:
                     self._span.set_attribute(key, str(value))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/tracing.py` around lines 343 - 359, OTELSpan.set_attributes
currently turns None values into empty strings which can mask missing
attributes; update the method (set_attributes) so that when iterating
span_attributes it simply skips any key whose value is None instead of calling
self._span.set_attribute(key, ""); keep the existing handling for
str/int/float/bool and fallback to str(...) for other types, and leave the
name/type handling unchanged (i.e., still call
self._span.set_attribute("span.name", name) when name is provided).

326-341: OTELSpan.log — metadata values are always stringified, losing type information.

Line 341 converts all metadata values to str(value). OTEL attributes support int, float, bool, and str natively. Preserving native types would improve query filtering in observability backends.

Proposed fix
         if metadata:
             for key, value in metadata.items():
-                self._span.set_attribute(f"metadata.{key}", str(value))
+                if isinstance(value, (str, int, float, bool)):
+                    self._span.set_attribute(f"metadata.{key}", value)
+                else:
+                    self._span.set_attribute(f"metadata.{key}", str(value))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/tracing.py` around lines 326 - 341, OTELSpan.log currently
stringifies all metadata values; change the metadata loop in the log method to
preserve OTEL-native types by passing ints, floats, and bools directly to
self._span.set_attribute (and strings after applying the existing _truncate).
For any non-primitive types (e.g., dicts, objects), fall back to str(value)
before setting the attribute. Keep using the same _span.set_attribute call and
the existing _truncate behavior only for string values so that numeric and
boolean metadata remain typed for better querying.
experimental/otel/metrics.py (2)

31-36: _get_otel_enabled is duplicated across multiple modules.

This same function appears in otel_logging.py (line 39) and here (line 31), with identical logic. Consider extracting it to a shared utility (e.g., in attributes.py or a new config.py) to keep it DRY.

Also applies to: 39-44, 47-49

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@experimental/otel/metrics.py` around lines 31 - 36, Extract the duplicated
_get_otel_enabled function into a single shared helper (e.g., create or use
attributes.py or a new config.py) and have both experimental/otel/metrics.py and
experimental/otel/otel_logging.py import and call that single helper instead of
defining their own copies; remove the local _get_otel_enabled definitions in
metrics.py and otel_logging.py, add a clear function name (e.g.,
_get_otel_enabled) exported from the shared module, and update the
imports/usages in both modules to reference the shared symbol.

61-66: Multiple global statements for mutable module-level state.

This pattern works but is fragile under concurrent initialization. Since init_otel_metrics is expected to be called once at startup, this is acceptable, but consider adding a brief comment noting the single-initialization assumption.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@experimental/otel/metrics.py` around lines 61 - 66, Add a brief comment above
the module-level globals stating that these variables (_meter, _meter_provider,
_token_usage_histogram, _operation_duration_histogram, _tool_duration_histogram,
_agent_iterations_counter, _tool_calls_counter, _error_counter) are intended to
be initialized once at startup by init_otel_metrics and are not thread-safe;
this documents the single-initialization assumption and concurrency fragility so
future readers know why multiple global statements are used and that
init_otel_metrics must be called once before concurrent use.
experimental/otel/__init__.py (1)

1-100: Eager imports of all submodules at package load time.

Importing all four submodules (attributes, metrics, otel_logging, tracing) at package load means any from experimental.otel import X triggers loading every submodule. Since heavy dependencies (OTLP exporters, AWS sessions) are deferred to function calls within those modules, this is acceptable for now. If startup cost becomes a concern, consider lazy imports.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@experimental/otel/__init__.py` around lines 1 - 100, The package currently
eagerly imports everything from experimental.otel.attributes, metrics,
otel_logging, and tracing on module load (symbols like AGENT_ITERATION,
get_meter, OTELContextFormatter, get_tracer, etc.), causing unnecessary startup
work; change __init__.py to use lazy imports instead: remove the top-level from
... import ... lines and implement a module-level lazy loader (e.g., __getattr__
and __dir__ or importlib.import_module) that imports the appropriate submodule
on first access and returns the requested symbol (map attribute names such as
AGENT_ITERATION, increment_errors, OTELContextFormatter, get_tracer,
init_otel_tracer, etc., to their source submodules), and keep a defined __all__
listing the exported names so external users can still do from experimental.otel
import get_meter or AGENT_ITERATION without triggering all submodules upfront.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/otel-tracing.md`:
- Around line 256-278: Consolidate the small "Debug Logging" subsection into the
main "Troubleshooting" block: remove the separate "Debug Logging" header and
replace it with bold inline text (e.g., **Debug logging**) and merge all
troubleshooting commands into a single annotated code block containing the
environment and debug commands (e.g., setting OTEL_SDK_DISABLED=false, checking
HOLMES_AWS_OSIS_PROFILE, note about payload truncation, and the LOG_LEVEL=DEBUG
python server.py command) with short comments for each line; edit the sections
referencing the quoted issues ("OTEL tracing requested but OTEL_SDK_DISABLED is
not set to 'false'", "Failed to create OSIS session", "payload too large") and
the existing LOG_LEVEL example so they appear as explanatory lines surrounding
one unified fenced code block instead of separate headers.
- Around line 17-52: The fenced ASCII diagram blocks (the triple-backtick blocks
containing the "Entry Points / TracingFactory / OTLP Exporter" diagram and the
span hierarchy fences) are missing language identifiers and trigger MD040;
update each opening fence from ``` to ```text (or ```plaintext) so the diagram
is treated as plain text; ensure you update the main ASCII diagram block and the
other affected fenced blocks (the second occurrence referenced in the comment)
so both render warnings are resolved.

In `@experimental/ag-ui/server-agui.py`:
- Around line 72-86: The NameError occurs because _NoopSpan and _NoopTracer are
only defined inside the ImportError fallback, but when _otel_available is True
and OTEL_SDK_DISABLED is set the code references _NoopTracer from the else
branch and crashes; fix by defining the no-op classes (_NoopSpan and
_NoopTracer) unconditionally (move their definitions out of the ImportError
branch to module scope) or ensure both branches import/define them before use so
the code paths that check _otel_available and OTEL_SDK_DISABLED can safely
instantiate _NoopTracer; apply the same change for the duplicate definitions
referenced later around the other instrumentation block (the second
_NoopSpan/_NoopTracer usage).

In `@experimental/otel/attributes.py`:
- Around line 158-178: The truncate function can return a string longer than
max_size when max_size is smaller than len(TRUNCATION_MARKER); update truncate
(referencing truncate, TRUNCATION_MARKER, MAX_ATTRIBUTE_SIZE) to first handle
non-positive max_size by returning "" and then handle the small-max case by
returning at most the first max_size characters of TRUNCATION_MARKER when
max_size <= len(TRUNCATION_MARKER); otherwise keep the existing logic but ensure
the sliced prefix length is max_size - len(TRUNCATION_MARKER) so the final
string never exceeds max_size.

In `@experimental/otel/test_otel_integration.py`:
- Around line 107-121: In test_tracer_initialization and any helper functions in
this test module that currently import modules inside function bodies, move
those imports to module scope at the top of
experimental/otel/test_otel_integration.py and remove inline imports; also add
explicit return type hints (e.g., -> bool, -> int or -> None) to every helper
function and test helper used in this file so signatures satisfy the project's
mypy rules; ensure the test still resets tracing._initialized and
tracing._tracer_provider and continues to call init_otel_tracer() unchanged.

In `@experimental/otel/test_otel.py`:
- Around line 19-27: The test function test_truncate_function imports
MAX_ATTRIBUTE_SIZE and truncate inside the function and lacks a return type;
move the import "from experimental.otel.attributes import MAX_ATTRIBUTE_SIZE,
truncate" to the module top alongside other imports, and add an explicit return
annotation "-> None" to test_truncate_function (and any other test helpers) so
mypy/type-checking passes; keep behavior unchanged and ensure only import and
signature changes touch the function.
- Around line 252-360: Tests and examples use "gpt-4" model IDs; change them to
the Claude model ID (e.g., "claude-4.5"). Update all occurrences of "gpt-4" in
this file including calls like record_token_usage, record_operation_duration,
increment_iterations, increment_tool_calls, log_llm_call,
build_stream_event_llm_iteration_start,
build_stream_event_llm_iteration_complete, and any assertion comparing
event.data["model"] or similar to use "claude-4.5" so tests and examples comply
with the Anthropic Claude requirement.

In `@holmes/core/tool_calling_llm.py`:
- Around line 953-965: The inline comments around the context-check events are
restating the code; change them to explain the rationale: clarify that the calls
to build_stream_event_context_check before and after limit_input_context_window
are used to create OTEL trace spans/events that correlate context-trimming
operations (limit_input_context_window) with the surrounding stream processing
for observability and debugging; update the two comments adjacent to
build_stream_event_context_check and the limit_input_context_window call to
mention intent (e.g., "start/end OTEL event to correlate context trimming with
trace spans for visibility into message windowing performed by
limit_input_context_window") so future readers understand why the events are
emitted.

In `@holmes/core/tracing.py`:
- Around line 466-499: CompositeSpan.start_span currently passes span_type
positionally which breaks Braintrust's Span.start_span expecting a named
parameter type (string); change the delegation to call start_span with explicit
keywords (e.g. s.start_span(name=name, span_type=span_type, **kwargs)) and
handle Braintrust spans by detecting that span object (or by duck-typing on the
start_span signature) and passing type=<mapped_string> where <mapped_string>
converts your SpanType enum to one of Braintrust's allowed strings
("llm","score","function","eval","task","tool"); update CompositeSpan.start_span
to branch per span kind (OTELSpan/DummySpan use span_type=..., Braintrust Span
use type=<mapped_string>) so calls match each implementation's parameter names
and types.

In `@server.py`:
- Around line 235-242: Replace the current docstring for otel_tracing_middleware
(and/or the inline comment above the middleware) with a concise "why"
explanation: state that when otel_enabled and a tracer is created via
TracingFactory.create_tracer("otel"), this middleware establishes root
OpenTelemetry spans for incoming requests so request latency can be correlated
with downstream spans and traces are linked across services (or remove the
comment if you prefer no explanation). Keep the wording short and focused on
intent (correlation and distributed tracing), and reference
otel_tracing_middleware and TracingFactory.create_tracer("otel") so reviewers
can find it easily.
- Around line 63-91: The function init_otel lacks a return type annotation and
performs a local import; hoist the optional import by adding a module-level
try/except ImportError that sets a module symbol (e.g., init_otel_metrics =
None) so the dependency remains optional, then update init_otel to declare a
return type (-> bool) and use the hoisted init_otel_metrics symbol (check for
None before calling) to determine metrics_ok; ensure the function always returns
a boolean (True if tracing_ok or metrics_ok, False otherwise) and preserves the
existing logging behavior.

In `@tests/test_otel_tracing.py`:
- Around line 193-206: In TestOTELAttributes, hoist per-test imports (e.g., from
experimental.otel.attributes import truncate, MAX_ATTRIBUTE_SIZE) out of each
test function to the module top and add explicit return type annotations to each
test method (e.g., def test_truncate_none_returns_empty(self) -> None, def
test_truncate_short_string_unchanged(self) -> None, def
test_truncate_exact_limit_unchanged(self) -> None) so mypy sees top-level
imports and typed test methods; update all test methods in this class that
import truncate or MAX_ATTRIBUTE_SIZE inside the body accordingly.

---

Duplicate comments:
In `@experimental/otel/attributes.py`:
- Around line 14-31: The code imports unstable symbols from
opentelemetry.semconv._incubating (e.g., GEN_AI_AGENT_NAME,
GEN_AI_CONVERSATION_ID, GEN_AI_OPERATION_NAME, GEN_AI_PROVIDER_NAME,
GEN_AI_REQUEST_MAX_TOKENS, GEN_AI_REQUEST_MODEL, GEN_AI_REQUEST_TEMPERATURE,
GEN_AI_RESPONSE_FINISH_REASONS, GEN_AI_RESPONSE_MODEL, GEN_AI_SYSTEM,
GEN_AI_TOKEN_TYPE, GEN_AI_TOOL_CALL_ID, GEN_AI_TOOL_NAME,
GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GenAiOperationNameValues)
which risks breakage; fix by either (A) copying these constant definitions and
GenAiOperationNameValues into this module (experimental/otel/attributes.py) and
replacing the imports with local definitions, or (B) enforce a pinned
opentelemetry version in our dependency config and add a comment referencing the
pinned version; choose (A) if you want stability without pinning, ensure the
copied values match upstream exactly and keep a comment noting origin and
upstream version.

In `@experimental/otel/metrics.py`:
- Around line 86-101: The code currently uses endpoint.replace("/v1/traces",
"/v1/metrics") (and directly imports the private _create_osis_session) which can
leave the metrics exporter pointed at the wrong path and couples metrics to a
private tracing implementation; change the logic in the block that calls
needs_aws_auth(endpoint) to (1) compute the metrics endpoint robustly by
parsing/normalizing the URL and ensuring the path ends with /v1/metrics (e.g.,
using urljoin/urllib.parse to replace or append the path instead of str.replace
so a bare base URL gets the correct /v1/metrics path and detect if replacement
was a no-op and log a warning), and (2) stop importing the private
_create_osis_session from experimental.otel.tracing — use a shared, public
factory (e.g., move session creation into a common helper like an exported
create_osis_session in a new/shared module and call that) so OTLPMetricExporter
is created with a validated metrics_endpoint and a non-private session creator.

In `@experimental/otel/otel_logging.py`:
- Around line 113-147: The log_llm_call function's previous falsy-check bug for
cost_usd needs to be fixed by checking explicitly for None; update the cost_usd
conditional in log_llm_call to use "if cost_usd is not None:" so 0.0 is logged,
ensure the formatted cost string uses "{cost_usd:.6f}", and keep the existing
logger.info(" ".join(msg_parts)) behavior (symbols: log_llm_call, cost_usd,
logger.info, msg_parts, iteration).

In `@holmes/core/tracing.py`:
- Around line 547-567: The change correctly guards TracingFactory.init_otel
against missing OTEL experimental modules by checking
OTEL_EXPERIMENTAL_AVAILABLE before calling init_otel_tracer; leave the
OTEL_EXPERIMENTAL_AVAILABLE check in place, ensure the method continues to set
TracingFactory._otel_initialized from the init_otel_tracer result, and keep the
try/except that logs failures around init_otel_tracer to avoid a TypeError when
the experimental module is None (references: TracingFactory.init_otel,
OTEL_EXPERIMENTAL_AVAILABLE, init_otel_tracer,
TracingFactory._otel_initialized).
- Around line 11-17: Apply the same guarded import pattern used for
opentelemetry (the otel_trace import and OTEL_API_AVAILABLE flag) to the
experimental module imports in this file (the imports around lines 42-52): wrap
each optional import in try/except ImportError, set a corresponding availability
boolean (e.g., <MODULE_NAME>_AVAILABLE) and assign the module variable to None
with a type: ignore fallback on ImportError, and ensure any downstream checks
use those availability flags before referencing the modules.
- Around line 387-398: Ensure _ensure_initialized sets self._initialized = True
when OTEL_EXPERIMENTAL_AVAILABLE is False and does not set self._native_tracer
so start_trace can fall back to DummySpan; keep the current guard logic in
_ensure_initialized, call init_otel_tracer() and set self._native_tracer =
get_tracer(self._service_name) only when OTEL_EXPERIMENTAL_AVAILABLE is True,
and verify start_trace inspects self._native_tracer before using it (so
start_trace/DummySpan fallback works).

---

Nitpick comments:
In `@experimental/otel/__init__.py`:
- Around line 1-100: The package currently eagerly imports everything from
experimental.otel.attributes, metrics, otel_logging, and tracing on module load
(symbols like AGENT_ITERATION, get_meter, OTELContextFormatter, get_tracer,
etc.), causing unnecessary startup work; change __init__.py to use lazy imports
instead: remove the top-level from ... import ... lines and implement a
module-level lazy loader (e.g., __getattr__ and __dir__ or
importlib.import_module) that imports the appropriate submodule on first access
and returns the requested symbol (map attribute names such as AGENT_ITERATION,
increment_errors, OTELContextFormatter, get_tracer, init_otel_tracer, etc., to
their source submodules), and keep a defined __all__ listing the exported names
so external users can still do from experimental.otel import get_meter or
AGENT_ITERATION without triggering all submodules upfront.

In `@experimental/otel/metrics.py`:
- Around line 31-36: Extract the duplicated _get_otel_enabled function into a
single shared helper (e.g., create or use attributes.py or a new config.py) and
have both experimental/otel/metrics.py and experimental/otel/otel_logging.py
import and call that single helper instead of defining their own copies; remove
the local _get_otel_enabled definitions in metrics.py and otel_logging.py, add a
clear function name (e.g., _get_otel_enabled) exported from the shared module,
and update the imports/usages in both modules to reference the shared symbol.
- Around line 61-66: Add a brief comment above the module-level globals stating
that these variables (_meter, _meter_provider, _token_usage_histogram,
_operation_duration_histogram, _tool_duration_histogram,
_agent_iterations_counter, _tool_calls_counter, _error_counter) are intended to
be initialized once at startup by init_otel_metrics and are not thread-safe;
this documents the single-initialization assumption and concurrency fragility so
future readers know why multiple global statements are used and that
init_otel_metrics must be called once before concurrent use.

In `@experimental/otel/otel_logging.py`:
- Around line 249-252: Replace the explicit str() call inside the f-string in
the logger.error call so it uses the f-string conversion flag; in the block
where err_type is set (err_type = error_type or type(error).__name__) and
logger.error is invoked, change the message to use {error!s} (e.g., "Agent
error: {agent_name} error_type={err_type} message={error!s}") and keep
exc_info=True unchanged so Ruff RUF010 is satisfied and formatting remains
consistent.
- Around line 39-54: Remove the two unused private helper functions
_get_logs_enabled and _get_log_content_enabled from
experimental/otel/otel_logging.py (they are defined but never called); if you
intended to keep them as placeholders instead, add a short docstring comment
above each explaining they are intentionally unused and add a # noqa or explicit
usage comment to silence linters—otherwise simply delete the function
definitions and any related unused imports or references.

In `@holmes/config.py`:
- Around line 191-246: The load_from_file method currently uses the deprecated
Pydantic v2 .dict() call when reconstructing the Config instance; update
load_from_file to call model_dump() instead (mirror the pattern used in
load_from_env), so replace the .dict() usage in load_from_file with model_dump()
when creating merged_config and then pass that to Config(...) or cls(...);
reference the load_from_file function and the merged_config variable to locate
the change.

In `@holmes/core/tracing.py`:
- Around line 343-359: OTELSpan.set_attributes currently turns None values into
empty strings which can mask missing attributes; update the method
(set_attributes) so that when iterating span_attributes it simply skips any key
whose value is None instead of calling self._span.set_attribute(key, ""); keep
the existing handling for str/int/float/bool and fallback to str(...) for other
types, and leave the name/type handling unchanged (i.e., still call
self._span.set_attribute("span.name", name) when name is provided).
- Around line 326-341: OTELSpan.log currently stringifies all metadata values;
change the metadata loop in the log method to preserve OTEL-native types by
passing ints, floats, and bools directly to self._span.set_attribute (and
strings after applying the existing _truncate). For any non-primitive types
(e.g., dicts, objects), fall back to str(value) before setting the attribute.
Keep using the same _span.set_attribute call and the existing _truncate behavior
only for string values so that numeric and boolean metadata remain typed for
better querying.

In `@holmes/utils/stream.py`:
- Around line 237-241: The hardcoded truncation value 8192 is duplicated and
should be replaced by a named constant; add a local constant (e.g.,
MAX_ATTR_SIZE or LOCAL_MAX_ATTRIBUTE_SIZE) near the top of
holmes/utils/stream.py and use it wherever tool_arguments
(data["tool_arguments"]) is truncated (and the similar truncation at lines
handling other attributes around 272-273) so the truncation uses that constant
instead of the magic number; update occurrences of the literal 8192 to reference
the new constant (keep the same numeric value).
- Line 325: The variable "data" in holmes/utils/stream.py is declared as data =
{} without a type annotation while similar builder variables use dict[str, Any];
update the declaration of data (the local variable in the builder function where
data is set) to include an explicit type hint (data: dict[str, Any] = {}) so it
matches the other annotated variables (see the other builder variables declared
as dict[str, Any]) and keeps typing consistent across the function(s) using
data.

In `@tests/test_bash_session_prefix_flow.py`:
- Around line 43-49: Remove the redundant reassignment of mock_response.usage to
a new MagicMock: since mock_response is already a MagicMock, its .usage child is
auto-created, so delete the line "mock_response.usage = MagicMock()" and leave
the subsequent assignments (mock_response.usage.prompt_tokens,
.completion_tokens, .total_tokens) as-is so the test still sets the token counts
while avoiding an unnecessary override of the existing mock object.

Comment thread docs/otel-tracing.md
Comment on lines +17 to +52
```
┌─────────────────────────────────────────────────────────────────┐
│ Entry Points │
├─────────────────────────────────────────────────────────────────┤
│ CLI (holmes ask) │ server.py │ AG-UI (server-agui.py) │
└─────────┬──────────┴──────┬──────┴──────────┬────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ TracingFactory │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Braintrust │ │ OTEL │ │ CompositeTracer │ │
│ │ Tracer │ │ Tracer │ │ (dual tracing support) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ │
│ ▼
│ ┌─────────────────────────────────────┐
│ │ experimental/otel/ │
│ │ ┌─────────┐ ┌─────────┐ ┌───────┐ │
│ │ │tracing │ │metrics │ │logging│ │
│ │ └─────────┘ └─────────┘ └───────┘ │
│ └─────────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ OTLP Exporter │
│ (with optional AWS SigV4 for OSIS endpoints) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Observability Backend │
│ (OpenSearch/OSIS, Jaeger, Zipkin, etc.) │
└─────────────────────────────────────────────────────────────────┘
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add language identifiers to the ASCII diagram and span hierarchy fences.

Markdownlint (MD040) flags these fenced blocks without a language. Use text/plaintext to keep rendering stable.

Also applies to: 85-93

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 17-17: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/otel-tracing.md` around lines 17 - 52, The fenced ASCII diagram blocks
(the triple-backtick blocks containing the "Entry Points / TracingFactory / OTLP
Exporter" diagram and the span hierarchy fences) are missing language
identifiers and trigger MD040; update each opening fence from ``` to ```text (or
```plaintext) so the diagram is treated as plain text; ensure you update the
main ASCII diagram block and the other affected fenced blocks (the second
occurrence referenced in the comment) so both render warnings are resolved.

Comment thread docs/otel-tracing.md
Comment on lines +256 to +278
## Troubleshooting

### Common Issues

**"OTEL tracing requested but OTEL_SDK_DISABLED is not set to 'false'"**

Set `OTEL_SDK_DISABLED=false` in your environment.

**"Failed to create OSIS session"**

Check your AWS credentials and ensure `HOLMES_AWS_OSIS_PROFILE` points to a valid profile with OSIS permissions.

**"payload too large" errors**

Tool outputs are automatically truncated to 8KB. If you still see this error, check for large metadata values.

### Debug Logging

Enable debug logging to see OTEL initialization details:

```bash
LOG_LEVEL=DEBUG python server.py
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Consolidate troubleshooting commands into a single annotated block.

The separate “Debug Logging” subheader is a small section. Consider replacing the subheader with bold text and keeping troubleshooting commands in one code block with comments.

As per coding guidelines: "docs/**/*.md: In MkDocs documentation, don't create a header for every small section. Use headers sparingly for major sections...andConsolidate troubleshooting commands in documentation into a single code block with comments instead of separate headers for each scenario`."

🧰 Tools
🪛 LanguageTool

[style] ~270-~270: Consider an alternative verb to strengthen your wording.
Context: ...atically truncated to 8KB. If you still see this error, check for large metadata va...

(IF_YOU_HAVE_THIS_PROBLEM)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/otel-tracing.md` around lines 256 - 278, Consolidate the small "Debug
Logging" subsection into the main "Troubleshooting" block: remove the separate
"Debug Logging" header and replace it with bold inline text (e.g., **Debug
logging**) and merge all troubleshooting commands into a single annotated code
block containing the environment and debug commands (e.g., setting
OTEL_SDK_DISABLED=false, checking HOLMES_AWS_OSIS_PROFILE, note about payload
truncation, and the LOG_LEVEL=DEBUG python server.py command) with short
comments for each line; edit the sections referencing the quoted issues ("OTEL
tracing requested but OTEL_SDK_DISABLED is not set to 'false'", "Failed to
create OSIS session", "payload too large") and the existing LOG_LEVEL example so
they appear as explanatory lines surrounding one unified fenced code block
instead of separate headers.

Comment thread experimental/ag-ui/server-agui.py
Comment on lines +158 to +178
def truncate(value: Optional[str], max_size: int = MAX_ATTRIBUTE_SIZE) -> str:
"""Truncate a string value to prevent OTEL payload size errors.

Based on OTEL best practices for attribute size limits.
See:
- OTEL attribute limits: https://opentelemetry.io/docs/specs/otel/common/#attribute-limits

Args:
value: The string to truncate (can be None)
max_size: Maximum allowed size (default 8KB)

Returns:
Original string if within limits, otherwise truncated with marker.
Returns empty string if value is None.
"""
if value is None:
return ""
if len(value) <= max_size:
return value
# Account for marker length to stay within max_size
return value[: max_size - len(TRUNCATION_MARKER)] + TRUNCATION_MARKER

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

truncate produces output exceeding max_size when max_size < len(TRUNCATION_MARKER).

If a caller passes a small max_size (e.g., truncate("hello world", max_size=5)), the result would be "" + "...[TRUNCATED]" = "...[TRUNCATED]" (14 chars), exceeding the requested limit. While the default MAX_ATTRIBUTE_SIZE = 8192 makes this unlikely in practice, the function's contract says it respects max_size.

Proposed fix
 def truncate(value: Optional[str], max_size: int = MAX_ATTRIBUTE_SIZE) -> str:
     if value is None:
         return ""
     if len(value) <= max_size:
         return value
-    # Account for marker length to stay within max_size
-    return value[: max_size - len(TRUNCATION_MARKER)] + TRUNCATION_MARKER
+    # Account for marker length to stay within max_size
+    if max_size <= len(TRUNCATION_MARKER):
+        return value[:max_size]
+    return value[: max_size - len(TRUNCATION_MARKER)] + TRUNCATION_MARKER
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@experimental/otel/attributes.py` around lines 158 - 178, The truncate
function can return a string longer than max_size when max_size is smaller than
len(TRUNCATION_MARKER); update truncate (referencing truncate,
TRUNCATION_MARKER, MAX_ATTRIBUTE_SIZE) to first handle non-positive max_size by
returning "" and then handle the small-max case by returning at most the first
max_size characters of TRUNCATION_MARKER when max_size <=
len(TRUNCATION_MARKER); otherwise keep the existing logic but ensure the sliced
prefix length is max_size - len(TRUNCATION_MARKER) so the final string never
exceeds max_size.

Comment on lines +107 to +121
def test_tracer_initialization():
"""Test that tracer initializes with OSIS endpoint."""
print("\n" + "=" * 60)
print("Test: Tracer Initialization")
print("=" * 60)

# Reset module state for clean test
from experimental.otel import tracing

tracing._initialized = False
tracing._tracer_provider = None

from experimental.otel.tracing import init_otel_tracer

result = init_otel_tracer()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Move helper imports to module scope and add return type hints.

Several helpers import modules inside the function body and omit return annotations. Please hoist imports to the top and add -> bool/-> int (or -> None) as appropriate.

💡 Example adjustment
+from experimental.otel.tracing import init_otel_tracer
@@
-def test_tracer_initialization():
+def test_tracer_initialization() -> bool:
     """Test that tracer initializes with OSIS endpoint."""
@@
-    from experimental.otel.tracing import init_otel_tracer
-
     result = init_otel_tracer()

As per coding guidelines: "**/*.py: Type hints required (mypy configuration in pyproject.toml)andALWAYS place Python imports at the top of the file, not inside functions or methods`."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@experimental/otel/test_otel_integration.py` around lines 107 - 121, In
test_tracer_initialization and any helper functions in this test module that
currently import modules inside function bodies, move those imports to module
scope at the top of experimental/otel/test_otel_integration.py and remove inline
imports; also add explicit return type hints (e.g., -> bool, -> int or -> None)
to every helper function and test helper used in this file so signatures satisfy
the project's mypy rules; ensure the test still resets tracing._initialized and
tracing._tracer_provider and continues to call init_otel_tracer() unchanged.

Comment on lines +953 to +965
# Emit context check start event for OTEL tracing
yield build_stream_event_context_check(is_start=True)

limit_result = limit_input_context_window(
llm=self.llm, messages=messages, tools=tools
)
yield from limit_result.events
messages = limit_result.messages
metadata = metadata | limit_result.metadata

# Emit context check end event for OTEL tracing
yield build_stream_event_context_check(
is_start=False,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Rephrase OTEL event comments to capture the rationale.

Comments like “Emit context check start event for OTEL tracing” restate the code. Consider explaining the intent (e.g., correlating context trimming with trace spans) or removing them.

As per coding guidelines: "**/*.{js,ts,tsx,jsx,py,java,cs,go,rb,php}: Write clear, concise comments that explain 'why' rather than 'what'`."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/tool_calling_llm.py` around lines 953 - 965, The inline comments
around the context-check events are restating the code; change them to explain
the rationale: clarify that the calls to build_stream_event_context_check before
and after limit_input_context_window are used to create OTEL trace spans/events
that correlate context-trimming operations (limit_input_context_window) with the
surrounding stream processing for observability and debugging; update the two
comments adjacent to build_stream_event_context_check and the
limit_input_context_window call to mention intent (e.g., "start/end OTEL event
to correlate context trimming with trace spans for visibility into message
windowing performed by limit_input_context_window") so future readers understand
why the events are emitted.

Comment thread holmes/core/tracing.py
Comment on lines +466 to +499
class CompositeSpan:
"""Span that delegates to multiple underlying spans."""

def __init__(self, spans: List[Union[OTELSpan, DummySpan, Any]]):
self._spans = spans

def start_span(
self, name: Optional[str] = None, span_type: Optional[SpanType] = None, **kwargs
) -> "CompositeSpan":
"""Create child spans on all underlying spans."""
child_spans = [s.start_span(name, span_type, **kwargs) for s in self._spans]
return CompositeSpan(child_spans)

def log(self, *args, **kwargs) -> None:
"""Log to all underlying spans."""
for span in self._spans:
span.log(*args, **kwargs)

def set_attributes(self, **kwargs) -> None:
"""Set attributes on all underlying spans."""
for span in self._spans:
span.set_attributes(**kwargs)

def end(self) -> None:
"""End all underlying spans."""
for span in self._spans:
span.end()

def __enter__(self) -> "CompositeSpan":
return self

def __exit__(self, exc_type, exc_val, exc_tb) -> None:
for span in self._spans:
span.__exit__(exc_type, exc_val, exc_tb)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find all start_span implementations in the file and related files
rg -n 'def start_span' --type=py -B 2 -A 8

Repository: HolmesGPT/holmesgpt

Length of output: 4193


🏁 Script executed:

# Check the SpanType enum definition
rg -n 'class SpanType\|SpanType\s*=' --type=py -B 2 -A 5

Repository: HolmesGPT/holmesgpt

Length of output: 45


🏁 Script executed:

# Look for CompositeSpan instantiation and usage
rg -n 'CompositeSpan\(' --type=py -B 2 -A 2

Repository: HolmesGPT/holmesgpt

Length of output: 2929


🏁 Script executed:

# Check if Braintrust is integrated and how
rg -n 'braintrust\|BraintrustSpan' --type=py -B 2 -A 5

Repository: HolmesGPT/holmesgpt

Length of output: 45


🏁 Script executed:

# Search for any braintrust references
fd . -type f -name "*.py" -o -name "*.toml" -o -name "*.txt" | xargs rg -i "braintrust" 2>/dev/null || echo "No braintrust found"

Repository: HolmesGPT/holmesgpt

Length of output: 50565


🏁 Script executed:

# Check if there are other span implementations besides OTELSpan and DummySpan
rg -n 'class.*Span.*:' --type=py | grep -E 'Span|Tracer'

Repository: HolmesGPT/holmesgpt

Length of output: 1170


🏁 Script executed:

# Check dependencies in pyproject.toml or requirements files
find . -type f \( -name "pyproject.toml" -o -name "requirements*.txt" -o -name "setup.py" \) -exec grep -l "braintrust\|Braintrust" {} \;

Repository: HolmesGPT/holmesgpt

Length of output: 79


🏁 Script executed:

# Look at the type hint context more carefully - what is imported and used
head -50 holmes/core/tracing.py | grep -E 'import|from|Union|List|Any'

Repository: HolmesGPT/holmesgpt

Length of output: 729


🏁 Script executed:

# Check TracingFactory and how spans are composed
rg -n "TracingFactory\|create_tracer\|CompositeTracer" --type py -B 2 -A 5 holmes/

Repository: HolmesGPT/holmesgpt

Length of output: 45


🏁 Script executed:

# Look for any code that constructs CompositeSpan with Braintrust spans
rg -n "CompositeSpan.*braintrust\|braintrust.*CompositeSpan" --type py -B 3 -A 3

Repository: HolmesGPT/holmesgpt

Length of output: 45


🏁 Script executed:

# Check what tracers can create Braintrust spans
rg -n "class.*Tracer.*:" --type py | head -20

Repository: HolmesGPT/holmesgpt

Length of output: 420


🏁 Script executed:

# Check how tracers are created and composed
head -100 holmes/core/tracing.py | tail -50

Repository: HolmesGPT/holmesgpt

Length of output: 1602


🏁 Script executed:

# Look at BraintrustTracer.start_trace and what it returns
sed -n '175,250p' holmes/core/tracing.py

Repository: HolmesGPT/holmesgpt

Length of output: 2506


🏁 Script executed:

# Look at CompositeTracer.start_trace to see how it composes spans
sed -n '502,540p' holmes/core/tracing.py

Repository: HolmesGPT/holmesgpt

Length of output: 1377


🏁 Script executed:

# Check what BraintrustTracer returns from start_trace/start_span
rg -n "class BraintrustTracer" -A 60 holmes/core/tracing.py

Repository: HolmesGPT/holmesgpt

Length of output: 2117


🏁 Script executed:

# Check if CompositeTracer is ever used with both OTELTracer and BraintrustTracer
rg -n "CompositeTracer\|both.*tracer\|dual.*trac" --type py -B 3 -A 3 holmes/ tests/

Repository: HolmesGPT/holmesgpt

Length of output: 45


🏁 Script executed:

# Look at test_otel_tracing to see if composite spans with mixed types are tested
sed -n '114,150p' tests/test_otel_tracing.py

Repository: HolmesGPT/holmesgpt

Length of output: 1467


🏁 Script executed:

# Check what the Braintrust Span object signature is in the type hints
rg -n "Span.*start_span\|start_span.*Span" --type py -B 2 -A 5 holmes/

Repository: HolmesGPT/holmesgpt

Length of output: 45


🏁 Script executed:

# Look for type hints or imports of braintrust.Span
rg -n "from braintrust import\|braintrust.Span" --type py -B 2 -A 5

Repository: HolmesGPT/holmesgpt

Length of output: 45


🏁 Script executed:

# Check if there's any documentation or comments about Braintrust Span.start_span signature
rg -n "type.*string\|span_type.*enum\|Braintrust.*start_span" --type py -B 2 -A 2

Repository: HolmesGPT/holmesgpt

Length of output: 45


🏁 Script executed:

# Look at the actual usage pattern - does BraintrustTracer ever create nested spans?
sed -n '215,280p' holmes/core/tracing.py

Repository: HolmesGPT/holmesgpt

Length of output: 2573


🌐 Web query:

Braintrust SDK Span.start_span signature parameter name type string

💡 Result:

In the Braintrust Python SDK, Span.start_span(...) takes:

  • name: str | None
  • type: SpanTypeAttribute | None (i.e., a restricted string type, used for UI display/icons—not an arbitrary string) [1]

type is typically one of: "llm", "score", "function", "eval", "task", or "tool" (these are the recognized span type values in span_attributes). [2]

Sources: [1] (braintrust.dev) [2] (braintrust.dev)

Citations:


CompositeSpan.start_span passes span_type positionally but Braintrust Span expects type as parameter name.

Line 476 passes span_type (a SpanType enum) as the second positional argument to all underlying spans. For DummySpan and OTELSpan, the second parameter is also named span_type, which matches. However, when CompositeSpan contains a Braintrust Span object (returned by BraintrustTracer.start_trace), the positional argument mismatches the expected parameter name: Braintrust's Span.start_span signature expects type as the parameter name, not span_type. Additionally, Braintrust expects type to be a string value from a restricted set ("llm", "score", "function", "eval", "task", "tool"), while passing a SpanType enum positionally would cause unexpected behavior.

Consider refactoring to use keyword-based delegation: s.start_span(name=name, span_type=span_type, **kwargs) or normalize the argument for each span type in a type-aware manner.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@holmes/core/tracing.py` around lines 466 - 499, CompositeSpan.start_span
currently passes span_type positionally which breaks Braintrust's
Span.start_span expecting a named parameter type (string); change the delegation
to call start_span with explicit keywords (e.g. s.start_span(name=name,
span_type=span_type, **kwargs)) and handle Braintrust spans by detecting that
span object (or by duck-typing on the start_span signature) and passing
type=<mapped_string> where <mapped_string> converts your SpanType enum to one of
Braintrust's allowed strings ("llm","score","function","eval","task","tool");
update CompositeSpan.start_span to branch per span kind (OTELSpan/DummySpan use
span_type=..., Braintrust Span use type=<mapped_string>) so calls match each
implementation's parameter names and types.

Comment thread server.py
Comment on lines +63 to +91
def init_otel():
"""Initialize OTEL tracing and metrics for production observability if enabled.

Metrics are initialized here alongside tracing. The HTTP middleware (below)
handles per-request span creation; metrics recording happens in the AG-UI
endpoint and tool execution paths.
"""
if os.environ.get("OTEL_SDK_DISABLED", "true").lower() != "true":
# Initialize tracing
tracing_ok = TracingFactory.init_otel()
if tracing_ok:
logging.info("OTEL tracing enabled for Holmes server")
else:
logging.warning("OTEL tracing initialization failed")

# Initialize metrics
try:
from experimental.otel.metrics import init_otel_metrics

metrics_ok = init_otel_metrics()
if metrics_ok:
logging.info("OTEL metrics enabled for Holmes server")
else:
logging.warning("OTEL metrics initialization failed")
except ImportError:
logging.warning("OTEL metrics module not available")
metrics_ok = False

return tracing_ok or metrics_ok

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add a return type and hoist the optional metrics import to module scope.

init_otel has no return annotation, and importing init_otel_metrics inside the function breaks the “imports at top” rule. A top-level try/except ImportError keeps the dependency optional while satisfying the guideline.

💡 Suggested update
+try:
+    from experimental.otel.metrics import init_otel_metrics
+except ImportError:
+    init_otel_metrics = None
+
-def init_otel():
+def init_otel() -> bool:
@@
-        # Initialize metrics
-        try:
-            from experimental.otel.metrics import init_otel_metrics
-
-            metrics_ok = init_otel_metrics()
+        # Initialize metrics
+        metrics_ok = False
+        if init_otel_metrics:
+            metrics_ok = init_otel_metrics()
             if metrics_ok:
                 logging.info("OTEL metrics enabled for Holmes server")
             else:
                 logging.warning("OTEL metrics initialization failed")
-        except ImportError:
-            logging.warning("OTEL metrics module not available")
-            metrics_ok = False
+        else:
+            logging.warning("OTEL metrics module not available")

As per coding guidelines: "**/*.py: Type hints required (mypy configuration in pyproject.toml)andALWAYS place Python imports at the top of the file, not inside functions or methods`."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server.py` around lines 63 - 91, The function init_otel lacks a return type
annotation and performs a local import; hoist the optional import by adding a
module-level try/except ImportError that sets a module symbol (e.g.,
init_otel_metrics = None) so the dependency remains optional, then update
init_otel to declare a return type (-> bool) and use the hoisted
init_otel_metrics symbol (check for None before calling) to determine
metrics_ok; ensure the function always returns a boolean (True if tracing_ok or
metrics_ok, False otherwise) and preserves the existing logging behavior.

Comment thread server.py Outdated
Comment on lines +193 to +206
class TestOTELAttributes:
def test_truncate_none_returns_empty(self):
from experimental.otel.attributes import truncate

assert truncate(None) == ""

def test_truncate_short_string_unchanged(self):
from experimental.otel.attributes import truncate

short = "hello world"
assert truncate(short) == short

def test_truncate_exact_limit_unchanged(self):
from experimental.otel.attributes import MAX_ATTRIBUTE_SIZE, truncate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Hoist per-test imports and add -> None return annotations.

Several tests import modules inside the function body and omit return annotations. Please move these imports to module scope and add -> None to test methods for mypy compliance.

💡 Example adjustment
+from experimental.otel.attributes import truncate
@@
-    def test_truncate_none_returns_empty(self):
-        from experimental.otel.attributes import truncate
+    def test_truncate_none_returns_empty(self) -> None:
         assert truncate(None) == ""

As per coding guidelines: "**/*.py: Type hints required (mypy configuration in pyproject.toml)andALWAYS place Python imports at the top of the file, not inside functions or methods`."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_otel_tracing.py` around lines 193 - 206, In TestOTELAttributes,
hoist per-test imports (e.g., from experimental.otel.attributes import truncate,
MAX_ATTRIBUTE_SIZE) out of each test function to the module top and add explicit
return type annotations to each test method (e.g., def
test_truncate_none_returns_empty(self) -> None, def
test_truncate_short_string_unchanged(self) -> None, def
test_truncate_exact_limit_unchanged(self) -> None) so mypy sees top-level
imports and typed test methods; update all test methods in this class that
import truncate or MAX_ATTRIBUTE_SIZE inside the body accordingly.

goyamegh and others added 9 commits March 8, 2026 21:20
Add OTEL instrumentation to the experimental AG-UI chat endpoint for
observability and distributed tracing. Traces are exported to AWS OSIS
(OpenSearch Ingestion Service) using SigV4 authentication.

Signed-off-by: Megha Goyal <goyamegh@amazon.com>

Signed-off-by: Megha Goyal <goyamegh@amazon.com>
…rics

- Added LoggingSpanProcessor and LoggingSpanExporter to log span lifecycle events and export results for debugging.
- Implemented metrics helpers that are no-op when OTEL is disabled.
- Introduced structured logging helpers for LLM calls and tool executions.
- Created new stream events for LLM iterations to support OTEL tracing.
- Updated TracingFactory to support dual tracing with Braintrust and OpenTelemetry.
- Enhanced server.py to initialize OTEL tracing and added middleware for HTTP request spans.
- Refactored configuration loading to merge environment variables with config file settings.

Signed-off-by: Megha Goyal <goyamegh@amazon.com>

Signed-off-by: Megha Goyal <goyamegh@amazon.com>
… handling

Signed-off-by: Megha Goyal <goyamegh@amazon.com>

Signed-off-by: Megha Goyal <goyamegh@amazon.com>
…lemetry spans

Signed-off-by: Megha Goyal <goyamegh@amazon.com>
…s for improved tracing

Signed-off-by: Megha Goyal <goyamegh@amazon.com>

Signed-off-by: Megha Goyal <goyamegh@amazon.com>
Replace inline import in _get_version() with importlib.metadata.version()
to avoid circular import while keeping imports at top-level.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Megha Goyal <goyamegh@amazon.com>

Signed-off-by: Megha Goyal <goyamegh@amazon.com>
…ments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Megha Goyal <goyamegh@amazon.com>

Signed-off-by: Megha Goyal <goyamegh@amazon.com>
- Rename OTEL_ENABLED -> OTEL_SDK_DISABLED (per OTEL spec, inverted logic)
- Rename OTEL_METRICS_ENABLED -> OTEL_METRICS_EXPORTER=none (per OTEL spec)
- Rename OTEL_AWS_PROFILE -> HOLMES_AWS_OSIS_PROFILE (remove OTEL_ prefix)
- Rename OTEL_AWS_REGION -> HOLMES_AWS_OSIS_REGION (remove OTEL_ prefix)
- Update docs, tests, and all references

Addresses review comments from svrnm (OpenTelemetry maintainer):
- OTEL_SDK_DISABLED is the official spec variable for disabling the SDK
- AWS-specific variables must not use the OTEL_ namespace prefix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Megha Goyal <goyamegh@amazon.com>

Signed-off-by: Megha Goyal <goyamegh@amazon.com>
- Rename OTEL_AWS_SERVICE → HOLMES_AWS_OSIS_SERVICE with backwards-compat
  fallback (Comment HolmesGPT#10, svrnm)
- Align OTEL_DEBUG with OTEL spec OTEL_LOG_LEVEL=debug with backwards-compat
  fallback (Comment HolmesGPT#9, svrnm)
- Add ml-commons AgentTracer.java GitHub permalink (Comment HolmesGPT#3, kylehounslow)
- Document needs_aws_auth() as single source of truth with consumer list
  (Comment HolmesGPT#4, kylehounslow)
- Clarify otel_logging.py: logs NOT exported via OTLP, naming avoids
  shadowing builtin logging (Comment HolmesGPT#5, kylehounslow)
- Explain experimental/ placement: API evolving, removable via try/except
  no-op fallbacks (Comment HolmesGPT#6, kylehounslow)
- Clarify server.py middleware: tracing/metrics init in init_otel() above,
  middleware only handles per-request spans (Comment HolmesGPT#7, kylehounslow)
- Split env var docs into Standard OTEL / Holmes-Specific tables, add
  missing vars, add OSIS hyperlink (Comments HolmesGPT#1, HolmesGPT#9, HolmesGPT#10)
- Fix no-op tracer fallback in server-agui.py for when OTEL is unavailable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Megha Goyal <goyamegh@amazon.com>
@goyamegh
goyamegh force-pushed the feat/otel-tracing-agui branch from 24326fc to b65c52a Compare March 9, 2026 05:55
@Avi-Robusta

Copy link
Copy Markdown
Collaborator

Hey, Thanks for the PR
We have 3 different PR's for OTEL and are going with this OTEL implementation #1761

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.

6 participants