Skip to content

Commit afe020e

Browse files
authored
fix(iorails): Logger defaults to non-verbose (#2310)
1 parent 7015c87 commit afe020e

5 files changed

Lines changed: 109 additions & 21 deletions

File tree

docs/observability/logging/index.mdx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@ This outputs detailed information about:
3939
- Action executions
4040
- Flow transitions
4141

42+
### Verbose mode on `Guardrails` and `LLMRails`
43+
44+
The two entry points interpret `verbose=True` differently.
45+
46+
| Entry point | Effect of `verbose=True` |
47+
|-------------|--------------------------|
48+
| `LLMRails(config, verbose=True)` | Adds a verbose console handler at `INFO` to the root logger and logs LLM prompts and completions. |
49+
| `Guardrails(config, verbose=True)` | Calls `configure_logging(logging.DEBUG)`, attaching a stderr handler to the `nemoguardrails.guardrails` logger and setting it to `DEBUG`. |
50+
51+
When `Guardrails` falls back to `LLMRails` — because an `llm` was passed, or the config contains flows IORails does not support — both apply, because `verbose` is forwarded to the `LLMRails` instance it creates.
52+
53+
Note that `configure_logging` also stops the `nemoguardrails.guardrails` logger propagating to the root logger; see [OpenTelemetry logs](/observability/tracing/opentelemetry-logs) for what that means for handlers you attach yourself.
54+
55+
A default `Guardrails(config)` construction configures no logging at all, leaving handlers, levels, and formatting to your application.
56+
4257
## Explain Method
4358

4459
Get a quick summary of the last generation using the `explain()` method:

docs/observability/tracing/opentelemetry-logs.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ Log records emitted outside any guardrails request, such as startup, engine regi
150150
- Performance
151151
: At high log volumes or DEBUG level, log export can add measurable overhead. Use `BatchLogRecordProcessor` (as shown) rather than the synchronous `SimpleLogRecordProcessor` in production, and consider filtering at the logger level (`logging.getLogger("nemoguardrails").setLevel(logging.INFO)`) to limit what crosses the bridge.
152152
- Interaction with `propagate=False`
153-
: If your application calls `nemoguardrails.guardrails.configure_logging()` on a freshly initialized logger, that helper sets `propagate=False` on the `nemoguardrails.guardrails` logger to prevent duplicate console output. The flag is only set on the first call, when no handlers exist yet. Records from submodules under `nemoguardrails.guardrails.*` will then not reach the handler attached to `nemoguardrails`. To capture them, attach the handler to `nemoguardrails.guardrails` instead of (or in addition to) `nemoguardrails`.
153+
: `nemoguardrails.guardrails.configure_logging()` sets `propagate=False` on the `nemoguardrails.guardrails` logger to prevent duplicate console output. It runs when your application calls it directly, and when you construct `Guardrails(..., verbose=True)`. The flag is only set on the first call, when no handlers exist yet. Records from submodules under `nemoguardrails.guardrails.*` will then not reach the handler attached to `nemoguardrails`. To capture them, attach the handler to `nemoguardrails.guardrails` instead of (or in addition to) `nemoguardrails`. A default `Guardrails(...)` construction does not configure logging, so its records propagate normally.
154154

155155
## Related Resources
156156

nemoguardrails/guardrails/guardrails.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,18 @@ def __init__(
6868
LLMRails and logs a warning. Set ``require_iorails=True`` to raise a
6969
``ValueError`` instead — use this when IORails-only features such as
7070
OpenTelemetry metrics are required.
71+
72+
``verbose=True`` also routes this package's logs to stderr through ``configure_logging``;
73+
without it, handlers, levels and formatting are left to the calling application.
7174
"""
7275

7376
self.config = config
7477
self.verbose = verbose
7578

79+
# configure_logging attaches a handler and stops the package logger propagating, detaching
80+
# nemoguardrails.guardrails from the handlers the application installed on the root logger.
7681
if verbose:
7782
configure_logging(logging.DEBUG)
78-
else:
79-
configure_logging(logging.INFO)
8083

8184
if use_iorails:
8285
fallback_reason = IORails.unsupported_reason(config, llm)

tests/guardrails/test_guardrails.py

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class correctly delegates method calls with properly formatted parameters.
2020
"""
2121

2222
import json
23+
import logging
2324
from unittest.mock import AsyncMock, MagicMock, patch
2425

2526
import pytest
@@ -101,6 +102,25 @@ def mock_llm():
101102
return llm
102103

103104

105+
@pytest.fixture
106+
def pristine_package_logger():
107+
"""Hand back an unconfigured ``nemoguardrails.guardrails`` logger, restoring it afterward."""
108+
# A test inheriting the configured state cannot tell a fixed constructor from a broken one.
109+
logger = logging.getLogger("nemoguardrails.guardrails")
110+
saved_handlers = list(logger.handlers)
111+
saved_propagate = logger.propagate
112+
saved_level = logger.level
113+
logger.handlers.clear()
114+
logger.propagate = True
115+
logger.setLevel(logging.NOTSET)
116+
try:
117+
yield logger
118+
finally:
119+
logger.handlers[:] = saved_handlers
120+
logger.propagate = saved_propagate
121+
logger.setLevel(saved_level)
122+
123+
104124
class TestGuardrailsRouting:
105125
"""Tests to check the routing of requests to Guardrails between LLMRails and IORails"""
106126

@@ -304,7 +324,7 @@ def test_init_without_llm(self, mock_llmrails_class, _nemoguards_rails_config):
304324
assert guardrails.rails_engine == mock_llmrails_instance
305325

306326
@patch("nemoguardrails.guardrails.guardrails.LLMRails")
307-
def test_init_with_llm(self, mock_llmrails_class, _nemoguards_rails_config, mock_llm):
327+
def test_init_with_llm(self, mock_llmrails_class, _nemoguards_rails_config, mock_llm, pristine_package_logger):
308328
"""Test initialization with a custom LLM."""
309329
mock_llmrails_instance = MagicMock()
310330
mock_llmrails_class.return_value = mock_llmrails_instance
@@ -348,6 +368,52 @@ def test_init_without_llm_uses_iorails(self, mock_iorails_init, _content_safety_
348368
mock_iorails_init.assert_called_once_with(_content_safety_rails_config)
349369

350370

371+
class TestConstructionLoggingSideEffects:
372+
"""Constructing Guardrails must not take over the ``nemoguardrails.guardrails`` logger.
373+
374+
Doing so detaches it from the application's root handlers, silencing that subtree with no error.
375+
"""
376+
377+
@patch.object(IORails, "__init__", return_value=None)
378+
def test_default_construction_leaves_the_package_logger_propagating(
379+
self, _mock_iorails_init, _content_safety_rails_config, pristine_package_logger
380+
):
381+
"""A default construction leaves package records reaching ancestor handlers."""
382+
Guardrails(config=_content_safety_rails_config, use_iorails=True)
383+
384+
assert pristine_package_logger.propagate is True
385+
386+
@patch.object(IORails, "__init__", return_value=None)
387+
def test_default_construction_adds_no_handler_of_its_own(
388+
self, _mock_iorails_init, _content_safety_rails_config, pristine_package_logger
389+
):
390+
"""A default construction attaches no handler, leaving output routing to the application."""
391+
Guardrails(config=_content_safety_rails_config, use_iorails=True)
392+
393+
assert pristine_package_logger.handlers == []
394+
395+
@patch.object(IORails, "__init__", return_value=None)
396+
def test_records_logged_after_a_default_construction_stay_visible(
397+
self, _mock_iorails_init, _content_safety_rails_config, pristine_package_logger, caplog
398+
):
399+
"""A package record emitted after a construction still reaches caplog, which listens at root."""
400+
with caplog.at_level(logging.WARNING):
401+
Guardrails(config=_content_safety_rails_config, use_iorails=True)
402+
logging.getLogger("nemoguardrails.guardrails.iorails").warning("visible after construction")
403+
404+
assert "visible after construction" in caplog.text
405+
406+
@patch.object(IORails, "__init__", return_value=None)
407+
def test_verbose_construction_still_configures_the_package_logger(
408+
self, _mock_iorails_init, _content_safety_rails_config, pristine_package_logger
409+
):
410+
"""verbose=True still opts in to the package's own handler and debug level."""
411+
Guardrails(config=_content_safety_rails_config, use_iorails=True, verbose=True)
412+
413+
assert pristine_package_logger.handlers
414+
assert pristine_package_logger.level == logging.DEBUG
415+
416+
351417
class TestIORailsUnsupportedReason:
352418
"""Direct tests for ``IORails.unsupported_reason`` and ``IORails.can_handle``."""
353419

@@ -1777,7 +1843,9 @@ def test_pickle_preserves_llmrails_when_llm_was_passed(
17771843
assert mock_llmrails_init.call_count == 2
17781844

17791845
@patch.object(IORails, "__init__", return_value=None)
1780-
def test_getstate_preserves_verbose_true(self, mock_iorails_init, _nemoguards_rails_config):
1846+
def test_getstate_preserves_verbose_true(
1847+
self, mock_iorails_init, _nemoguards_rails_config, pristine_package_logger
1848+
):
17811849
"""__getstate__ captures verbose=True so a verbose Guardrails round-trips
17821850
with logging configuration intact."""
17831851
guardrails = Guardrails(config=_nemoguards_rails_config, verbose=True)
@@ -1793,7 +1861,9 @@ def test_setstate_restores_verbose_true(self, mock_iorails_init, _nemoguards_rai
17931861
assert guardrails.verbose is True
17941862

17951863
@patch.object(IORails, "__init__", return_value=None)
1796-
def test_pickle_round_trip_preserves_verbose(self, mock_iorails_init, _nemoguards_rails_config):
1864+
def test_pickle_round_trip_preserves_verbose(
1865+
self, mock_iorails_init, _nemoguards_rails_config, pristine_package_logger
1866+
):
17971867
"""Full round-trip: a Guardrails constructed with verbose=True must come
17981868
back from __getstate__/__setstate__ with verbose=True. Regression for the
17991869
bug where verbose was hardcoded to False on restore, silently obscuring

tests/recorded/rails/library/test_iorails_parity.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -103,27 +103,27 @@ def vcr_cassette_dir(request: pytest.FixtureRequest) -> str:
103103

104104
@pytest.fixture
105105
def rail_ran_cleanly(caplog: pytest.LogCaptureFixture):
106-
"""Fail the test if any rail errored, which is what an unreplayed cassette looks like.
107-
108-
Without this a blocked-case assertion is vacuous: a cassette that fails to replay raises
109-
inside the action, the fail-closed envelope turns that into a block naming the same rail
110-
with the same refusal text, and status, rail and content all still match. The rail logs at
111-
ERROR when it fails and does not when it reaches a verdict, so that is the difference.
112-
"""
113-
with caplog.at_level(logging.ERROR):
114-
yield
115-
# get_records("call") rather than .records: during teardown the latter reports the
116-
# teardown phase, which is empty, and the check would pass no matter what the rail did.
117-
# Restricted to this package's loggers because the question is whether a *rail* failed,
118-
# which rail_guard reports. Anything at ERROR would also catch aiohttp's unclosed-session
119-
# message, which the asyncio exception handler emits from __del__ whenever the collector
120-
# happens to run -- so an unrelated leak elsewhere in the suite would fail this test.
106+
"""Fail the test if any rail errored, which is what an unreplayed cassette looks like."""
107+
# caplog listens at root, so rail_guard's records only arrive while this logger propagates.
108+
# Set it rather than trust it: a verbose=True construction earlier closes it process-wide.
109+
package_logger = logging.getLogger("nemoguardrails.guardrails")
110+
was_propagating = package_logger.propagate
111+
package_logger.propagate = True
112+
try:
113+
with caplog.at_level(logging.ERROR):
114+
yield
115+
# Closed mid-test it is captured nowhere, leaving the errors below empty either way.
116+
assert package_logger.propagate, "the nemoguardrails.guardrails logger stopped propagating mid-test"
117+
# get_records("call"): .records would report the empty teardown phase here. Package loggers
118+
# only, so aiohttp's unclosed-session ERROR from an unrelated leak cannot fail the test.
121119
errors = [
122120
record.getMessage()
123121
for record in caplog.get_records("call")
124122
if record.levelno >= logging.ERROR and record.name.startswith("nemoguardrails")
125123
]
126124
assert not errors, f"a rail errored, so the cassette did not replay: {errors}"
125+
finally:
126+
package_logger.propagate = was_propagating
127127

128128

129129
async def check_iorails(config, messages: list[dict], rail_types: tuple[RailType, ...]):

0 commit comments

Comments
 (0)