Skip to content

feat(iorails): Telemetry - full non-streaming trace - #1794

Merged
tgasser-nv merged 15 commits into
developfrom
feat/iorails-telemetry-full-e2e
Apr 20, 2026
Merged

feat(iorails): Telemetry - full non-streaming trace#1794
tgasser-nv merged 15 commits into
developfrom
feat/iorails-telemetry-full-e2e

Conversation

@tgasser-nv

@tgasser-nv tgasser-nv commented Apr 15, 2026

Copy link
Copy Markdown
Collaborator

Description

This PR builds on top of the scaffolding introduced in #1793 to instrument the non-streaming inference path from generate_async(). It adds spans to reflect work units at different levels of hierarchy:

  • rail_span: Includes the rail type, direction and flow.
  • action_span: Includes action name being executed.
  • llm_call_span: Includes LLM provider, model, and operation.
  • api_call_span: Operation name set to API.

As part of the test-plan, I integration-tested IORails calls to generate_async() for the nemoguards config. See files and test results below.

Related Issue(s)

Preceeding PRs in the stack:

Test Plan

Pre-commit

$ poetry run pre-commit run --all-files
check yaml...............................................................Passed
fix end of files.........................................................Passed
trim trailing whitespace.................................................Passed
ruff (legacy alias)......................................................Passed
ruff format..............................................................Passed
Insert license in comments...............................................Passed
pyright..................................................................Passed

Unit-test

$ poetry run pytest -q
.......................ssss.........................................................................................s............................... [  4%]
.................................................................................................................................................... [  8%]
.................................................................................................................................................... [ 12%]
.................................................................................................................................................... [ 17%]
.................................................................................................................................................... [ 21%]
.................................................................................................................................................s.. [ 25%]
....ss...................................sssssss.................................................................................................... [ 29%]
.........................................................s.......s.........................................ss...........................s........... [ 34%]
....s.......sssss...............................................................s................................................................... [ 38%]
............ss........ss...ss............................................s.....................................................s............s....... [ 42%]
.................................................................................................................................................... [ 46%]
.................................................................................................................................................... [ 51%]
....................sssss......ssssssssssssssssss..........sssss.................................................................................... [ 55%]
s...........ss...................................sssssssss.ssssssssss.......................................s....................................... [ 59%]
............s....s........................................................ssssssss..............sss...ss...ss....................................... [ 63%]
............................ssssssssssssss.......................................................................................................... [ 68%]
............................s..............................................................................................................s........ [ 72%]
............ssssssss.........ss..................................................................................................................... [ 76%]
..............sssssss...........................................................................s................................................... [ 80%]
.................................................................................................................................................... [ 85%]
.................................................................................................................................................... [ 89%]
.............................................................................................................s...................................... [ 93%]
.................................................................................................................................................... [ 97%]
..........................................................................                                                                           [100%]
3339 passed, 139 skipped in 116.14s (0:01:56)

Integration test (uses trace_e2e_test.py)

$ NEMO_GUARDRAILS_IORAILS_ENGINE=1 poetry run python tests/guardrails/trace_e2e_test.py
2026-04-16 15:33:35 INFO: Registered model engine: type=main, model=meta/llama-3.3-70b-instruct, base_url=https://integrate.api.nvidia.com
2026-04-16 15:33:35 INFO: Registered model engine: type=content_safety, model=nvidia/llama-3.1-nemoguard-8b-content-safety, base_url=https://integrate.api.nvidia.com
2026-04-16 15:33:35 INFO: Registered model engine: type=topic_control, model=nvidia/llama-3.1-nemoguard-8b-topic-control, base_url=https://integrate.api.nvidia.com
2026-04-16 15:33:35 INFO: Registered API engine: name=jailbreak_detection, url=https://ai.api.nvidia.com/v1/security/nvidia/nemoguard-jailbreak-detect
2026-04-16 15:33:35 INFO: RailsManager initialized: input_flows=['content safety check input $model=content_safety', 'topic safety check input $model=topic_control', 'jailbreak detection model'], output_flows=['content safety check output $model=content_safety'], input_parallel=False, output_parallel=False
2026-04-16 15:33:35 INFO: [816832f3684d8c0d] generate_async called
2026-04-16 15:33:35 INFO: [816832f3684d8c0d] Running input rails
2026-04-16 15:33:35 INFO: [816832f3684d8c0d] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-content-safety'
DEBUG:aiohttp_retry:Attempt 1 out of 3
2026-04-16 15:33:36 INFO: [816832f3684d8c0d] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-topic-control'
DEBUG:aiohttp_retry:Attempt 1 out of 3
2026-04-16 15:33:36 INFO: [816832f3684d8c0d] HTTP POST https://ai.api.nvidia.com/v1/security/nvidia/nemoguard-jailbreak-detect
DEBUG:aiohttp_retry:Attempt 1 out of 3
2026-04-16 15:33:37 INFO: [816832f3684d8c0d] Calling main LLM
2026-04-16 15:33:37 INFO: [816832f3684d8c0d] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='meta/llama-3.3-70b-instruct'
DEBUG:aiohttp_retry:Attempt 1 out of 3
2026-04-16 15:33:38 INFO: [816832f3684d8c0d] Running output rails
2026-04-16 15:33:38 INFO: [816832f3684d8c0d] HTTP POST https://integrate.api.nvidia.com/v1/chat/completions model='nvidia/llama-3.1-nemoguard-8b-content-safety'
DEBUG:aiohttp_retry:Attempt 1 out of 3
2026-04-16 15:33:39 INFO: [816832f3684d8c0d] generate_async completed time=3164.8ms

=== NEMO_GUARDRAILS_IORAILS_ENGINE=1 → using Guardrails ===

=== Running generate_async ===
RESPONSE: {'role': 'assistant', 'content': "Hello. I'm doing well, thanks for asking. I'm a large language model, so I don't have feelings or emotions like humans do, but I'm functioning properly and ready to help with any questions or tasks you may have. How about you? How's your day going so far?"}

Attaching logs and JSON files with traces

20260416_otel.log
trace_e2e_test.json

Checklist

  • I've read the CONTRIBUTING guidelines.
  • I've updated the documentation if applicable.
  • I've added tests if applicable.
  • [] @mentions of the person or team responsible for reviewing proposed changes.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added OpenTelemetry tracing instrumentation to core guardrail execution flows, enabling detailed observability and monitoring of rail and action execution.
    • Implemented automatic error recording in trace spans for improved error tracking and diagnostics.
  • Tests

    • Added comprehensive test coverage for tracing helpers and end-to-end trace hierarchy validation.

@tgasser-nv
tgasser-nv changed the base branch from develop to feat/iorails-telemetry-initial-e2e April 15, 2026 21:28
@codecov

codecov Bot commented Apr 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR instruments the non-streaming generate_async path with OpenTelemetry spans, adding a four-level hierarchy: guardrails.request (SERVER) → guardrails.rail (INTERNAL) → guardrails.action (INTERNAL) → LLM/API CLIENT spans. The tracer is created once in IORails.__init__ and threaded through EngineRegistry, RailsManager, and RailAction constructors so that all spans share a consistent parent and no orphaned child spans leak when tracing is disabled. Previously flagged concerns — action span error recording being unreachable and the global singleton mutex in test fixtures — are fully resolved in this commit.

Confidence Score: 5/5

Safe to merge — all P0/P1 concerns from prior review rounds are resolved; only two P2 style suggestions remain.

Both previously flagged issues (action span error-recording unreachability and global singleton mutation in tests) are fully addressed. The remaining findings are minor style nits — a redundant local variable and an inconsistent test teardown pattern — neither of which affects correctness or reliability.

No files require special attention. The test teardown pattern in test_iorails_telemetry.py::TestOtelNotInstalled is worth a quick cleanup but is not blocking.

Important Files Changed

Filename Overview
nemoguardrails/guardrails/telemetry.py New OTEL instrumentation module with span helpers for request, rail, action, LLM, and API calls; includes graceful no-op paths when OTEL is unavailable.
nemoguardrails/guardrails/iorails.py Adds traced_request wrapper around generate_async; minor redundancy in the local tracer variable (always equals self._tracer).
nemoguardrails/guardrails/rails_manager.py Threads tracer through constructor and wraps _run_rail with rail_span; mark_rail_stop correctly called after action completes.
nemoguardrails/guardrails/rail_action.py Wraps run() with action_span; record_span_error is called in the except block before swallowing the exception, so action spans correctly show ERROR status on failures.
nemoguardrails/guardrails/engine_registry.py Adds tracer param, wraps model_call with llm_call_span and api_call with api_call_span; _get_engine is correctly resolved before llm_call_span so the model name is available for the span name.
nemoguardrails/tracing/constants.py Adds SpanNames, GuardrailsAttributes, OperationNames, and GenAIAttributes constants following OTEL GenAI semantic conventions.
tests/guardrails/test_iorails_telemetry.py Comprehensive integration tests covering span hierarchy, error recording, and concurrent trace isolation; TestOtelNotInstalled directly mutates the tracer singleton rather than using patch.object for exception-safe teardown.
tests/guardrails/test_telemetry.py Unit tests for all telemetry helpers; uses autouse fixture to reset the singleton between tests.
tests/guardrails/test_telemetry_spans.py Unit tests for rail_span, action_span, llm_call_span, and api_call_span; covers attributes, no-op with None tracer, and exception propagation.

Sequence Diagram

sequenceDiagram
    participant Client
    participant IORails
    participant RailsManager
    participant RailAction
    participant EngineRegistry

    Client->>IORails: generate_async(messages)
    IORails->>IORails: traced_request → guardrails.request span (SERVER)

    loop Each input rail
        IORails->>RailsManager: is_input_safe(messages)
        RailsManager->>RailsManager: rail_span → guardrails.rail [Input]
        RailsManager->>RailAction: action.run(flow, messages)
        RailAction->>RailAction: action_span → guardrails.action
        RailAction->>EngineRegistry: model_call / api_call
        EngineRegistry->>EngineRegistry: llm_call_span / api_call_span (CLIENT)
        EngineRegistry-->>RailAction: response
        RailAction-->>RailsManager: RailResult
        RailsManager->>RailsManager: mark_rail_stop if blocked
    end

    IORails->>EngineRegistry: model_call("main", messages)
    EngineRegistry->>EngineRegistry: llm_call_span (CLIENT, parent=request)
    EngineRegistry-->>IORails: response_text

    loop Each output rail
        IORails->>RailsManager: is_output_safe(messages, response)
        RailsManager->>RailsManager: rail_span → guardrails.rail [Output]
        RailsManager->>RailAction: action.run(flow, messages, bot_response)
        RailAction->>RailAction: action_span → guardrails.action
        RailAction->>EngineRegistry: model_call / api_call
        EngineRegistry->>EngineRegistry: llm_call_span / api_call_span (CLIENT)
        EngineRegistry-->>RailAction: response
        RailAction-->>RailsManager: RailResult
    end

    IORails-->>Client: {"role": "assistant", "content": ...}
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: nemoguardrails/guardrails/iorails.py
Line: 139

Comment:
**Redundant local `tracer` variable**

`self._tracer` is already `None` when tracing is disabled (set in `__init__` as `get_tracer() if self._tracing_enabled else None`), so the local rebinding here always equals `self._tracer`. Simplifying to `with traced_request(self._tracer) as req_id:` removes the indirection without changing behaviour.

```suggestion
        with traced_request(self._tracer) as req_id:
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: tests/guardrails/test_iorails_telemetry.py
Line: 645-646

Comment:
**Direct singleton mutation not restored by `patch.object`**

`telemetry._tracer = None` is a bare assignment; if the test fails after line 646 and before the `with patch.object(telemetry, "_OTEL_AVAILABLE", False)` block exits, the singleton remains `None` for subsequent tests in this module. The rest of the file uses `patch.object(telemetry, "_tracer", ...)` which handles teardown atomically. Using the same pattern here makes cleanup exception-safe:

```suggestion
    with patch.object(telemetry, "_OTEL_AVAILABLE", False), patch.object(telemetry, "_tracer", None):
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (5): Last reviewed commit: "Add docstring to explain hard-coded chat..." | Re-trigger Greptile

Comment thread nemoguardrails/guardrails/rail_action.py
Comment thread tests/guardrails/test_iorails_telemetry.py Outdated
@tgasser-nv
tgasser-nv marked this pull request as draft April 16, 2026 01:36
Base automatically changed from feat/iorails-telemetry-initial-e2e to develop April 16, 2026 18:34
@tgasser-nv
tgasser-nv force-pushed the feat/iorails-telemetry-full-e2e branch from 22964c1 to 11d16e2 Compare April 16, 2026 18:47
@tgasser-nv tgasser-nv self-assigned this Apr 16, 2026
@tgasser-nv
tgasser-nv marked this pull request as ready for review April 16, 2026 20:43
@tgasser-nv

Copy link
Copy Markdown
Collaborator Author

@greptile Review latest commit and update summary and score

@coderabbitai

coderabbitai Bot commented Apr 16, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

OpenTelemetry instrumentation is integrated into the guardrails engine to trace execution flows. New helper functions wrap model calls, API calls, rail actions, and rail execution with telemetry spans. A telemetry module provides span context managers and error recording utilities. Comprehensive tests validate span hierarchy, attributes, and behavior across the instrumented components.

Changes

Cohort / File(s) Summary
Core Telemetry Infrastructure
nemoguardrails/guardrails/telemetry.py
Adds five new OTEL context manager helpers (rail_span, action_span, llm_call_span, api_call_span) for creating and configuring spans, plus record_span_error() utility for centralizing exception recording. Each helper optionally creates CLIENT or INTERNAL spans, sets metadata attributes, and records errors before re-raising.
Engine Registry Instrumentation
nemoguardrails/guardrails/engine_registry.py
Wraps model_call() in llm_call_span() context with model name and provider attributes. Wraps api_call() in api_call_span() context. Stream handling unchanged except for a TODO comment about streaming instrumentation.
Rail Action & Manager Instrumentation
nemoguardrails/guardrails/rail_action.py, nemoguardrails/guardrails/rails_manager.py
RailAction.run() initializes a tracer and wraps pipeline execution in action_span(), recording exceptions on the span. RailsManager._run_rail() wraps RailAction.run() invocations in rail_span() context, computing direction from flow type and setting GuardrailsAttributes.RAIL_STOP on blocked rails.
Stream Handling Comments
nemoguardrails/guardrails/iorails.py
Adds a TODO comment in _wrapped_iterator() noting that streaming instrumentation is deferred to a follow-on PR. No functional changes.
Telemetry Tests
tests/guardrails/test_telemetry.py, tests/guardrails/test_telemetry_spans.py
New unit tests verify record_span_error() behavior with and without spans, and test all four span helper context managers for correct span creation, attributes, error handling, and no-op behavior when tracer is None.
End-to-End Span Hierarchy Tests
tests/guardrails/test_iorails_telemetry.py
Enhanced fixture patches the tracer singleton and adds engine-level mocks via _stub_deep_pipeline(). New TestSpanHierarchy class with async tests validating parent/child span relationships, rail/action attributes, blocked-rail behavior, unsafe-path short-circuiting, and error propagation through the full RailsManager → RailAction → EngineRegistry → Model/API call chain.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 59.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding telemetry instrumentation for the non-streaming inference trace path with hierarchical OpenTelemetry spans.
Test Results For Major Changes ✅ Passed PR documentation includes comprehensive test results: 3,339 unit tests passed, 139 skipped, pre-commit hooks passed, integration testing performed with logs and trace files, and three new test files with 295+ lines of tests validating span creation, attributes, hierarchies, and error handling.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/iorails-telemetry-full-e2e

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 (2)
tests/guardrails/test_iorails_telemetry.py (1)

598-600: Strengthen this assertion to avoid false positives.

Line 599 currently passes for any non-empty set, so it won’t catch wrong model attribution. Prefer asserting the expected model (or an explicit allowlist) directly.

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

In `@tests/guardrails/test_iorails_telemetry.py` around lines 598 - 600, The
assertion on models_seen is too weak and can pass for any non-empty set; update
the test to assert that the expected model(s) are present explicitly (e.g.,
require "nvidia/llama-3.1-nemoguard-8b-content-safety" to be in models_seen) or
validate models_seen against a small explicit allowlist; locate the models_seen
set (built from llm_spans using s.attributes["gen_ai.request.model"]) and
replace the current assert with one that checks membership or that models_seen
is subset/equal to the expected models collection.
nemoguardrails/guardrails/telemetry.py (1)

162-273: Consider consolidating repeated span error handling via record_span_error.

Lines 185-188, 209-212, 243-247, and 269-273 duplicate the same exception/status pattern. Reusing record_span_error in these blocks will keep behavior consistent and reduce drift.

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

In `@nemoguardrails/guardrails/telemetry.py` around lines 162 - 273, The except
blocks in rail_span, action_span, llm_call_span, and api_call_span duplicate the
same exception recording and status-setting logic; replace each duplicated
except Exception as exc: block with a single call to the shared helper
record_span_error(span, exc) (and then re-raise), making sure record_span_error
handles span.record_exception(exc), span.set_status(StatusCode.ERROR, str(exc))
and sets "error.type" where needed for client spans; update the four functions
(rail_span, action_span, llm_call_span, api_call_span) to call
record_span_error(span, exc) instead of repeating the three lines so behavior is
centralized and consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@nemoguardrails/guardrails/engine_registry.py`:
- Around line 140-143: The code unconditionally calls get_tracer() and creates
llm_call_span around engine.chat_completion which can emit OTEL spans even when
Guardrails tracing is disabled; update the logic in engine_registry.py to only
call get_tracer() and enter llm_call_span when the runtime flag _tracing_enabled
(the same flag used by IORails) is true, or change the API to accept an optional
tracer passed in from the caller and only use it when provided; apply the same
gated change to the other occurrence around lines 175-179 so tracer lookup and
span creation are skipped when tracing is disabled.

In `@nemoguardrails/guardrails/rails_manager.py`:
- Around line 147-153: The code currently infers direction with "direction =
RailDirection.INPUT if flow in self.input_flows else RailDirection.OUTPUT",
which can misclassify rails present in both lists; change the method to accept
an explicit direction argument from the caller and use that value when calling
rail_span(tracer, flow, direction) instead of checking self.input_flows. Update
the caller sites to pass the correct RailDirection, keep the rest of the logic
unchanged (retain action = self._actions[flow], await action.run(...), and the
span.set_attribute(GuardrailsAttributes.RAIL_STOP, True) handling), and remove
the membership-based inference to prevent incorrect telemetry.

---

Nitpick comments:
In `@nemoguardrails/guardrails/telemetry.py`:
- Around line 162-273: The except blocks in rail_span, action_span,
llm_call_span, and api_call_span duplicate the same exception recording and
status-setting logic; replace each duplicated except Exception as exc: block
with a single call to the shared helper record_span_error(span, exc) (and then
re-raise), making sure record_span_error handles span.record_exception(exc),
span.set_status(StatusCode.ERROR, str(exc)) and sets "error.type" where needed
for client spans; update the four functions (rail_span, action_span,
llm_call_span, api_call_span) to call record_span_error(span, exc) instead of
repeating the three lines so behavior is centralized and consistent.

In `@tests/guardrails/test_iorails_telemetry.py`:
- Around line 598-600: The assertion on models_seen is too weak and can pass for
any non-empty set; update the test to assert that the expected model(s) are
present explicitly (e.g., require "nvidia/llama-3.1-nemoguard-8b-content-safety"
to be in models_seen) or validate models_seen against a small explicit
allowlist; locate the models_seen set (built from llm_spans using
s.attributes["gen_ai.request.model"]) and replace the current assert with one
that checks membership or that models_seen is subset/equal to the expected
models collection.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a30f5773-ac43-447f-b00e-b76c86d54935

📥 Commits

Reviewing files that changed from the base of the PR and between 032a661 and 8aca47f.

📒 Files selected for processing (8)
  • nemoguardrails/guardrails/engine_registry.py
  • nemoguardrails/guardrails/iorails.py
  • nemoguardrails/guardrails/rail_action.py
  • nemoguardrails/guardrails/rails_manager.py
  • nemoguardrails/guardrails/telemetry.py
  • tests/guardrails/test_iorails_telemetry.py
  • tests/guardrails/test_telemetry.py
  • tests/guardrails/test_telemetry_spans.py

Comment thread nemoguardrails/guardrails/engine_registry.py Outdated
Comment thread nemoguardrails/guardrails/rails_manager.py Outdated
@tgasser-nv

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit go through all comments, re-evaluate based on latest commit, and resolve if any issue has been fixed

@coderabbitai

This comment was marked as resolved.

@tgasser-nv

Copy link
Copy Markdown
Collaborator Author

@greptile re-evaluate latest commit and update summary and score

@Pouyanpi Pouyanpi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, thanks! please merge after reviewing the comments.

@Pouyanpi note for refactor:

Each helper (rail_span, action_span, llm_call_span, api_call_span) calls span.set_attribute(GuardrailsAttributes.RAIL_TYPE, ) directly, with the same constants Pydantic models use in to_otel_attributes(). This is exactly what tracing/span_attributes.py was designed to avoid. when the refactor comes, these helpers should delegate to apply_attributes(span, rail_attributes(...)).

Comment thread nemoguardrails/guardrails/telemetry.py Outdated
Comment thread nemoguardrails/guardrails/rails_manager.py
Comment thread nemoguardrails/guardrails/telemetry.py
@tgasser-nv
tgasser-nv merged commit feafe39 into develop Apr 20, 2026
7 checks passed
@tgasser-nv
tgasser-nv deleted the feat/iorails-telemetry-full-e2e branch April 20, 2026 21:53
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.

2 participants