-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Add user-provided OpenTelemetry configuration support #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
- Add UserOpenTelemetryConfig dataclass for parsing user OTEL config from AGENTUITY_USER_OTEL_CONF env var - Implement create_user_logger_provider() to create user OTLP logger providers - Add multi-delegate logging system that sends logs to both Agentuity and user endpoints - Enhance create_logger() to automatically add MultiDelegateHandler when user providers exist - Add proper shutdown handling for user logger providers with flush and cleanup - Implement graceful error handling that doesn't break existing functionality - Add comprehensive test coverage with 25+ new tests - Maintain full backward compatibility with existing OTEL functionality - Add ruff as dev dependency for code linting consistency This implementation mirrors the functionality from the JavaScript SDK PR #176, allowing users to send agent logs to their own OTLP endpoints alongside the default Agentuity telemetry collection. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-1dd20dcd-70fb-4f19-9de2-1984bb3b9a05
WalkthroughAdds optional user-provided OpenTelemetry support and a multi-delegate logging path: parse env config, create/register OTLP logger providers, forward Python LogRecords to user providers via a MultiDelegateHandler, integrate provider lifecycle into init() and server shutdown, add tests, and introduce a dev dependency group. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant App as Application
participant Init as otel.init()
participant Logger as Python Logger
participant MDH as MultiDelegateHandler
participant Emit as emit_to_user_providers()
participant Provider as User OTLP Provider
participant SDK as OpenTelemetry SDK
Note over Init,Provider: init() may parse env and create provider
App->>Init: start / init()
Init->>Provider: create_user_logger_provider() [optional]
Provider-->>Init: registered
Logger->>MDH: create_logger() attaches MDH
Logger->>Logger: logger.handle(LogRecord)
Logger->>MDH: MDH.emit(record)
MDH->>Emit: emit_to_user_providers(record)
Emit->>Emit: map level -> SeverityNumber\nmerge attributes, timestamp (ns)
Emit->>Provider: provider.get_logger(name) -> emit(event)
Provider->>SDK: OTLP export
SDK-->>Provider: ack / error
alt Provider raises
Provider--X Emit: exception
Emit-->>Logger: warning logged, continue
else Success
Provider-->>Emit: success
end
App->>Init: shutdown
Init->>Provider: shutdown()/force-close
Provider-->>Init: cleared
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 6
🧹 Nitpick comments (13)
pyproject.toml (1)
49-53: Avoid duplicate dev deps and align ruff versions.You now declare ruff in two places: [project.optional-dependencies].dev ("ruff>=0.1.0") and [dependency-groups].dev ("ruff>=0.11.11"). This can drift and confuse tooling (uv vs pip extras). Pick one source of truth or keep both but pin the same spec.
Apply either:
- Remove ruff from [project.optional-dependencies].dev and rely on [dependency-groups] for uv-only workflows; or
- Keep extras.dev and drop [dependency-groups]; or
- Keep both but use identical constraints.
Also, please verify that "0.11.11" is an intended version and actually exists for ruff.
agentuity/otel/__init__.py (3)
16-26: Dataclass defaults: prefer concrete dicts over Optionals when you set {} later.parse_user_otel_config returns {} for resource_attributes and headers, while dataclass fields are Optional. Make them Dict[...] = field(default_factory=dict) to simplify consumers and types.
-@dataclass -class UserOpenTelemetryConfig: +@dataclass +class UserOpenTelemetryConfig: """Configuration for user-provided OpenTelemetry setup.""" endpoint: str service_name: str - resource_attributes: Optional[Dict[str, Any]] = None - headers: Optional[Dict[str, str]] = None + resource_attributes: Dict[str, Any] = field(default_factory=dict) + headers: Dict[str, str] = field(default_factory=dict)
28-56: Normalize and validate headers/attributes on parse.User-provided headers/attributes can contain non-string/invalid types. Normalize header keys to lowercase and coerce non-primitive attribute values to strings to avoid exporter errors.
- return UserOpenTelemetryConfig( + headers = {str(k).lower(): str(v) for k, v in (config_data.get("headers", {}) or {}).items()} + attrs = config_data.get("resourceAttributes", {}) or {} + safe_attrs = {str(k): (v if isinstance(v, (str, int, float, bool)) else str(v)) for k, v in attrs.items()} + return UserOpenTelemetryConfig( endpoint=config_data["endpoint"], service_name=config_data["serviceName"], - resource_attributes=config_data.get("resourceAttributes", {}), - headers=config_data.get("headers", {}) + resource_attributes=safe_attrs, + headers=headers )
63-111: User logger provider creation looks solid; consider compression and timeouts.Recommend enabling gzip and making timeouts configurable to harden network IO without changing defaults.
- exporter = OTLPLogExporter( + exporter = OTLPLogExporter( endpoint=logs_url, headers=user_config.headers or {}, - timeout=10, + timeout=int(os.environ.get("AGENTUITY_USER_OTEL_TIMEOUT", "10")), + compression=os.environ.get("AGENTUITY_USER_OTEL_COMPRESSION", "gzip"), )agentuity/otel/logger.py (2)
9-14: Dedup and type the provider registry.Prevent duplicate registrations and add a type for better tooling. Optional: guard with a lock or id(provider) set if concurrency matters.
-from typing import List +from typing import List, Any, Set +import threading @@ -_user_logger_providers: List = [] +_user_logger_providers: List[Any] = [] +_user_provider_ids: Set[int] = set() +_user_providers_lock = threading.Lock() @@ def add_user_logger_provider(provider): """Add a user logger provider to the multi-delegate system.""" - global _user_logger_providers - _user_logger_providers.append(provider) + global _user_logger_providers, _user_provider_ids + with _user_providers_lock: + pid = id(provider) + if pid in _user_provider_ids: + return + _user_logger_providers.append(provider) + _user_provider_ids.add(pid) logging.info("Added user logger provider to multi-delegate system")
16-63: Make emission robust: snapshot providers, pre-import SeverityNumber, and sanitize attributes.
- Snapshot providers to avoid concurrent modification during iteration.
- Import SeverityNumber once at module scope.
- Only pass OTEL-supported attribute types; coerce others to str to avoid exporter errors.
-from opentelemetry._logs import SeverityNumber +# at top-level +from opentelemetry._logs import SeverityNumber +_SEVERITY_MAP = { + logging.DEBUG: SeverityNumber.DEBUG, + logging.INFO: SeverityNumber.INFO, + logging.WARNING: SeverityNumber.WARN, + logging.ERROR: SeverityNumber.ERROR, + logging.CRITICAL: SeverityNumber.FATAL, +} @@ - for provider in _user_logger_providers: + # Snapshot to avoid mutation during iteration + providers = list(_user_logger_providers) + for provider in providers: @@ - # Convert log level to OpenTelemetry severity - from opentelemetry._logs import SeverityNumber - - severity_map = { - logging.DEBUG: SeverityNumber.DEBUG, - logging.INFO: SeverityNumber.INFO, - logging.WARNING: SeverityNumber.WARN, - logging.ERROR: SeverityNumber.ERROR, - logging.CRITICAL: SeverityNumber.FATAL, - } - - severity = severity_map.get(record.levelno, SeverityNumber.INFO) + severity = _SEVERITY_MAP.get(record.levelno, SeverityNumber.INFO) @@ - for key, value in record.__dict__.items(): + for key, value in record.__dict__.items(): if not key.startswith('_') and key not in ['name', 'msg', 'args', 'levelname', 'levelno', 'pathname', 'filename', 'module', 'lineno', 'funcName', 'created', 'msecs', 'relativeCreated', 'thread', 'threadName', 'processName', 'process']: - attributes[key] = value + if isinstance(value, (str, int, float, bool)): + attributes[key] = value + else: + attributes[key] = str(value)tests/otel/test_user_otel.py (2)
125-138: Narrow the import-error patch to reduce blast radius.Patching builtins.import can mask unrelated imports. Prefer targeting the exporter import path.
-with patch('builtins.__import__', side_effect=ImportError("No module")): +with patch('opentelemetry.exporter.otlp.proto.http._log_exporter.OTLPLogExporter', side_effect=ImportError("No module")):
190-232: Add assertions for processor/exporter shutdown once implemented.If you adopt flushing processor/exporter in shutdown, add asserts for both to be called, and keep provider shutdown assert.
- mock_provider.force_flush.assert_called_once() - mock_provider.shutdown.assert_called_once() + mock_provider.shutdown.assert_called_once() + # New if implemented + result = agentuity.otel._user_logger_provider + # Example if you expose processor/exporter mocks: + # mock_processor.force_flush.assert_called_once() + # mock_exporter.shutdown.assert_called_once()tests/otel/test_user_logger.py (5)
15-21: Consider using a fixture or public API instead of directly mutating global state.The tests directly call
.clear()on the module-level_user_logger_providerslist. This creates tight coupling to internal implementation details and makes the tests fragile to refactoring. If_user_logger_providersis later changed to a different data structure or moved behind an API, all these tests will break.Consider one of these approaches:
- Add a public
clear_user_logger_providers()function to the module- Use a pytest fixture that automatically resets state:
@pytest.fixture(autouse=True) def reset_user_logger_providers(): _user_logger_providers.clear() yield _user_logger_providers.clear()This pattern repeats in all test classes (lines 182-187, 211-217), so fixing it once would improve all tests.
48-60: Inconsistent LogRecord creation across tests.This test creates a
LogRecordwithout explicitly settingmoduleandfuncNameattributes, but other tests (e.g., lines 79-80) manually set these attributes after creation. This inconsistency could mask bugs if the code under test depends on these attributes being present.Consider creating a helper function to create fully-populated test LogRecords:
def create_test_log_record(level=logging.INFO, msg="Test message", **extra_attrs): record = logging.LogRecord( name="test", level=level, pathname="/path/test.py", lineno=42, msg=msg, args=(), exc_info=None ) record.module = "test" record.funcName = "test_function" for key, value in extra_attrs.items(): setattr(record, key, value) return record
244-257: Clarify what the duplicate handler test is verifying.The test creates a grandchild logger (
child1→child2) but only checkschild2.handlers. Due to Python's logging hierarchy, this might not be testing what's intended. The test should clarify:
- Are you checking that
child2doesn't have duplicate handlers on itself?- Or that the handler isn't added redundantly when creating nested loggers?
Consider making the test more explicit:
# Create logger twice child1 = create_logger(parent_logger, "child", {"attr1": "value1"}) child2 = create_logger(child1, "grandchild", {"attr2": "value2"}) - # Should only have one MultiDelegateHandler + # child1 should have exactly one MultiDelegateHandler + multi_handlers_child1 = [h for h in child1.handlers if isinstance(h, MultiDelegateHandler)] + assert len(multi_handlers_child1) == 1 + + # child2 should also have exactly one (not duplicate when nested) multi_handlers = [h for h in child2.handlers if isinstance(h, MultiDelegateHandler)] assert len(multi_handlers) == 1
259-282: Test doesn't verify attributes in actual logging integration.The test manually applies filters to a LogRecord, simulating what the logging framework does. However, this doesn't test the actual integration—whether attributes correctly flow through when a real log message is emitted via the logger.
Consider adding an integration test that actually logs and verifies the full flow:
def test_create_logger_attributes_in_actual_logging(self): """Test that attributes are added when actually logging.""" mock_otel_logger = MagicMock() mock_provider = MagicMock() mock_provider.get_logger.return_value = mock_otel_logger add_user_logger_provider(mock_provider) parent_logger = logging.getLogger("test_parent") child = create_logger(parent_logger, "child", {"custom_attr": "test_value"}) # Actually log a message child.info("Test message") # Verify the custom attribute was passed through to OTEL mock_otel_logger.emit.assert_called() call_kwargs = mock_otel_logger.emit.call_args.kwargs assert 'custom_attr' in call_kwargs['attributes'] assert call_kwargs['attributes']['custom_attr'] == 'test_value'
1-282: Consider adding tests for timestamp conversion and message formatting.The test coverage is good for the main flows, but some details are untested:
- Timestamp conversion (line 44 in
emit_to_user_providers): No test verifies thatrecord.createdis correctly converted to nanoseconds- Message body: No test verifies the
bodyparameter passed toemit()matchesrecord.getMessage()- End-to-end integration: No test actually calls
logger.info(),logger.error(), etc., and verifies the complete flow through to user providersConsider adding tests like:
def test_emit_timestamp_conversion(self): """Verify timestamp is converted to nanoseconds.""" mock_otel_logger = MagicMock() mock_provider = MagicMock() mock_provider.get_logger.return_value = mock_otel_logger add_user_logger_provider(mock_provider) record = create_test_log_record() record.created = 1234567890.123456 # seconds with microseconds emit_to_user_providers(record) call_kwargs = mock_otel_logger.emit.call_args.kwargs expected_ns = int(1234567890.123456 * 1_000_000_000) assert call_kwargs['timestamp'] == expected_ns def test_integration_actual_logging(self): """Test complete flow with actual logging calls.""" # Set up user provider mock_otel_logger = MagicMock() mock_provider = MagicMock() mock_provider.get_logger.return_value = mock_otel_logger add_user_logger_provider(mock_provider) # Create logger and log message logger = logging.getLogger("integration_test") child = create_logger(logger, "test", {"session_id": "abc123"}) child.warning("Integration test message") # Verify OTEL emission happened with correct parameters mock_otel_logger.emit.assert_called_once() call_kwargs = mock_otel_logger.emit.call_args.kwargs assert call_kwargs['body'] == "Integration test message" assert 'session_id' in call_kwargs['attributes'] assert call_kwargs['attributes']['session_id'] == "abc123"
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
uv.lockis excluded by!**/*.lock
📒 Files selected for processing (6)
agentuity/otel/__init__.py(4 hunks)agentuity/otel/logger.py(2 hunks)agentuity/server/__init__.py(2 hunks)pyproject.toml(1 hunks)tests/otel/test_user_logger.py(1 hunks)tests/otel/test_user_otel.py(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
agentuity/server/__init__.py (1)
agentuity/otel/__init__.py (2)
init(113-214)shutdown(217-232)
agentuity/otel/__init__.py (1)
agentuity/otel/logger.py (1)
add_user_logger_provider(9-13)
tests/otel/test_user_otel.py (1)
agentuity/otel/__init__.py (4)
parse_user_otel_config(28-56)UserOpenTelemetryConfig(17-25)create_user_logger_provider(63-110)shutdown(217-232)
tests/otel/test_user_logger.py (1)
agentuity/otel/logger.py (6)
add_user_logger_provider(9-13)emit_to_user_providers(16-62)MultiDelegateHandler(65-70)create_logger(73-104)emit(68-70)filter(90-94)
🔇 Additional comments (2)
agentuity/server/__init__.py (2)
17-17: Import rename LGTM.Clear alias for shutdown; no issues.
751-762: Graceful shutdown wiring LGTM.on_shutdown callback is correct; errors are contained; keeps server resilient.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
agentuity/otel/__init__.py (1)
163-201: Critical: resource_attributes may be undefined when Traceloop import failsresource_attributes is created inside the try block but used later (Lines 209-211). If the import of traceloop fails before assignment, init will raise UnboundLocalError. Define resource_attributes outside the try and reuse it for Traceloop.init.
@@ - # Initialize traceloop for automatic instrumentation - try: - from traceloop.sdk import Traceloop - - headers = {"Authorization": f"Bearer {bearer_token}"} if bearer_token else {} - - resource_attributes = { - SERVICE_NAME: config.get( - "service_name", - app_name, - ), - SERVICE_VERSION: config.get( - "service_version", - app_version, - ), - "@agentuity/orgId": orgId, - "@agentuity/projectId": projectId, - "@agentuity/deploymentId": deploymentId, - "@agentuity/env": environment, - "@agentuity/devmode": devmode, - "@agentuity/sdkVersion": sdkVersion, - "@agentuity/cliVersion": cliVersion, - "@agentuity/language": "python", - "env": "dev" if devmode else "production", - "version": __version__, - } + # Build base resource attributes once; use for Traceloop and user OTEL + resource_attributes = { + SERVICE_NAME: config.get("service_name", app_name), + SERVICE_VERSION: config.get("service_version", app_version), + "@agentuity/orgId": orgId, + "@agentuity/projectId": projectId, + "@agentuity/deploymentId": deploymentId, + "@agentuity/env": environment, + "@agentuity/devmode": devmode, + "@agentuity/sdkVersion": sdkVersion, + "@agentuity/cliVersion": cliVersion, + "@agentuity/language": "python", + "env": "dev" if devmode else "production", + "version": __version__, + } + + # Initialize traceloop for automatic instrumentation + try: + from traceloop.sdk import Traceloop + headers = {"Authorization": f"Bearer {bearer_token}"} if bearer_token else {} @@ Traceloop.init( app_name=app_name, api_endpoint=endpoint, headers=headers, disable_batch=devmode, resource_attributes=resource_attributes, telemetry_enabled=False )Also applies to: 209-211
♻️ Duplicate comments (1)
agentuity/otel/__init__.py (1)
225-231: Shutdown order: flush processor/exporter before providerCall force_flush/shutdown on the processor and exporter before the provider to avoid tearing down the pipeline prematurely.
- # Shutdown components in reverse order: provider, processor, exporter - components = [ - ("provider", _user_logger_provider.get("provider")), - ("processor", _user_logger_provider.get("processor")), - ("exporter", _user_logger_provider.get("exporter")) - ] + # Flush and shutdown components in safe order: processor -> exporter -> provider + components = [ + ("processor", _user_logger_provider.get("processor")), + ("exporter", _user_logger_provider.get("exporter")), + ("provider", _user_logger_provider.get("provider")), + ]Also applies to: 236-258
🧹 Nitpick comments (3)
agentuity/otel/__init__.py (3)
79-86: Normalize endpoint when user supplies full OTLP pathIf endpoint already ends with /v1/logs (or /v1), appending /v1/logs creates invalid URLs. Normalize to accept both base and full paths.
- base_url = user_config.endpoint.rstrip('/') - logs_url = f"{base_url}/v1/logs" + base_url = user_config.endpoint.rstrip("/") + if base_url.endswith("/v1/logs"): + logs_url = base_url + elif base_url.endswith("/v1"): + logs_url = f"{base_url}/logs" + else: + logs_url = f"{base_url}/v1/logs"
119-121: Robust truthy parsing for AGENTUITY_OTLP_DISABLEDAccept common truthy forms (TRUE, True, 1, yes).
- if os.environ.get("AGENTUITY_OTLP_DISABLED", "false") == "true": + if os.environ.get("AGENTUITY_OTLP_DISABLED", "false").strip().lower() in {"true", "1", "yes"}:
28-50: Config parsing niceties: aliases and type guardsMinor DX polish: accept service_name/resource_attributes aliases and guard dict types.
- if not config_data.get("serviceName"): + service_name = config_data.get("serviceName") or config_data.get("service_name") + if not service_name: logger.warning("User OTEL config missing required 'serviceName' field, ignoring") return None @@ - return UserOpenTelemetryConfig( - endpoint=config_data["endpoint"], - service_name=config_data["serviceName"], - resource_attributes=config_data.get("resourceAttributes", {}), - headers=config_data.get("headers", {}) - ) + ra = config_data.get("resourceAttributes") or config_data.get("resource_attributes") or {} + if ra is not None and not isinstance(ra, dict): + logger.warning("User OTEL config 'resourceAttributes' must be an object; ignoring value") + ra = {} + hdrs = config_data.get("headers") or {} + if hdrs is not None and not isinstance(hdrs, dict): + logger.warning("User OTEL config 'headers' must be an object; ignoring value") + hdrs = {} + return UserOpenTelemetryConfig( + endpoint=config_data["endpoint"], + service_name=service_name, + resource_attributes=ra, + headers=hdrs, + )
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
agentuity/otel/__init__.py(4 hunks)tests/otel/test_user_otel.py(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- tests/otel/test_user_otel.py
🧰 Additional context used
🧬 Code graph analysis (1)
agentuity/otel/__init__.py (1)
agentuity/otel/logger.py (1)
add_user_logger_provider(13-24)
🔇 Additional comments (3)
agentuity/otel/__init__.py (3)
16-26: LGTM on UserOpenTelemetryConfigDataclass shape is clear and minimal; sensible defaults.
205-216: Remove the suggested refactor—no code paths call init multiple timesVerification found init() is called exactly once at agentuity/server/init.py:705 during server startup. No re-initialization paths exist in the codebase, so the defensive shutdown/idempotency logic is unnecessary.
63-91: Remove the suggested import path change; current private import is correct for 1.31.xThe public import path
opentelemetry.exporter.otlp.proto.http.log_exporterdoes not exist in OpenTelemetry Python 1.31.x. OTLPLogExporter is only provided under the private module path (opentelemetry.exporter.otlp.proto.http._log_exporter). The current code is correct and requires no changes.The optional gzip compression suggestion remains valid if needed for optimization.
Likely an incorrect or invalid review comment.
There was a problem hiding this comment.
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 (1)
tests/otel/test_user_logger.py (1)
119-166: Previous review feedback addressed; consider explicit attribute setting for clarity.The test now correctly verifies severity_number mapping (lines 161-164), addressing the prior review comment. However, the LogRecord created in lines 143-151 doesn't explicitly set
moduleandfuncName, whichemit_to_user_providersaccesses. While Python'sLogRecordinitializes these frompathname, explicitly setting them (as done intest_emit_to_user_providers_successlines 79-80) would improve test clarity and robustness.Apply this diff to explicitly set missing attributes:
record = logging.LogRecord( name="test", level=log_level, pathname="test.py", lineno=1, msg="Test message", args=(), exc_info=None ) + record.module = "test" + record.funcName = "test_func" # Call emit_to_user_providers emit_to_user_providers(record)
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
tests/otel/test_user_logger.py(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
tests/otel/test_user_logger.py (1)
agentuity/otel/logger.py (6)
add_user_logger_provider(13-24)emit_to_user_providers(27-73)MultiDelegateHandler(76-83)create_logger(86-120)emit(79-83)filter(103-107)
🔇 Additional comments (2)
tests/otel/test_user_logger.py (2)
167-209: LGTM! Previous review feedback fully addressed.The test now correctly verifies that custom attributes are included (lines 202-205) and private attributes are filtered out (lines 207-208), addressing the prior review comment. The implementation is clear and thorough.
211-261: LGTM!The
TestMultiDelegateHandlerclass correctly validates both the active path (delegates toemit_to_user_providerswhen providers exist) and the no-op path (does nothing when no providers are registered). The tests are clear and comprehensive.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (1)
tests/otel/test_user_logger.py (1)
362-388: Test still doesn't properly verify retroactive handler attachment.The test clears both
child.handlersand_logger_registry(line 376), then creates a new loggerchild2(line 379). Whenadd_user_logger_provideris called (line 384), the retroactive logic cannot attach a handler tochildbecause it was removed from the registry. Meanwhile,child2gets a handler immediately fromcreate_logger, so there's nothing retroactive to test.To properly test retroactive attachment, the test should:
- Create a logger that's in the registry but lacks a handler
- Add a provider (triggering retroactive attachment)
- Verify the handler was retroactively added to that logger
Based on learnings
Apply this diff to fix the test:
def test_retroactive_handler_attachment(self): """Test that handlers are retroactively attached to existing loggers when providers are added.""" + from agentuity.otel.logger import _logger_registry + parent_logger = logging.getLogger("test_retroactive") # Create logger before adding any providers child = create_logger(parent_logger, "child", {"attr1": "value1"}) # Should have one handler (the MultiDelegateHandler) assert len(child.handlers) == 1 assert isinstance(child.handlers[0], MultiDelegateHandler) - # Clear handler and logger registry, then recreate logger without providers + # Remove handler to simulate logger in old state (before provider was added) child.handlers.clear() - from agentuity.otel.logger import _logger_registry - _logger_registry.clear() + assert len(child.handlers) == 0 + # child should still be in _logger_registry at this point + assert child in _logger_registry - # Create logger again without any providers - child2 = create_logger(parent_logger, "child2", {"attr2": "value2"}) - assert len(child2.handlers) == 1 # Should still get handler - - # Now add a provider - should not add duplicate handlers + # Add provider - should trigger retroactive attachment to child mock_provider = MagicMock() add_user_logger_provider(mock_provider) - # Should still have only one handler (no duplicates) - assert len(child2.handlers) == 1 - assert isinstance(child2.handlers[0], MultiDelegateHandler) + # Verify handler was retroactively attached to child + assert len(child.handlers) == 1 + assert isinstance(child.handlers[0], MultiDelegateHandler)
🧹 Nitpick comments (1)
tests/otel/test_user_logger.py (1)
119-166: Past review issue resolved, but consider adding missing attributes for consistency.The severity mapping verification has been properly fixed and now correctly asserts that the expected
severity_numberis passed tootel_logger.emit()for each logging level.However, for consistency with other tests (e.g., lines 79-80), consider setting
moduleandfuncNameon the LogRecord:for log_level, expected_severity in severity_mapping.items(): # Reset mock calls for clean test mock_otel_logger.reset_mock() mock_provider.reset_mock() record = logging.LogRecord( name="test", level=log_level, pathname="test.py", lineno=1, msg="Test message", args=(), exc_info=None ) + record.module = "test" + record.funcName = "test_function" # Call emit_to_user_providers emit_to_user_providers(record)
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
tests/otel/test_user_logger.py(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
tests/otel/test_user_logger.py (1)
agentuity/otel/logger.py (6)
add_user_logger_provider(13-24)emit_to_user_providers(27-73)MultiDelegateHandler(76-83)create_logger(86-120)emit(79-83)filter(103-107)
🔇 Additional comments (7)
tests/otel/test_user_logger.py (7)
12-44: LGTM! Clean test setup and basic provider management.The setup/teardown pattern properly isolates tests by clearing the provider registry, and the basic provider addition tests are correct and straightforward.
46-117: LGTM! Emission tests cover key scenarios.The tests properly cover the no-provider path, successful emission, and error handling with appropriate verification of mocked calls and warning logs.
167-209: LGTM! Past review issue resolved.The test now properly verifies that custom attributes are included in the emitted attributes and that private attributes (those starting with
_) are correctly filtered out.
211-261: LGTM! Handler tests correctly verify conditional behavior.The tests properly verify that
MultiDelegateHandlerdelegates toemit_to_user_providerswhen providers exist and no-ops when no providers are registered.
263-299: LGTM! Basic create_logger tests are comprehensive.The tests correctly verify that
create_loggeradds aMultiDelegateHandlerin both scenarios (with and without user providers), with proper assertions on handler count, type, and level.
300-336: LGTM! Past review issue resolved.The test has been properly updated and now correctly verifies duplicate handler prevention. It simulates a scenario where a handler already exists and confirms that calling
create_loggeragain doesn't add another handler (stays at 2, not 3).
337-361: LGTM! Attributes filter test is well-designed.The test properly verifies that the
ContextFiltercreated bycreate_loggercorrectly adds custom attributes to log records.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
🧹 Nitpick comments (2)
tests/otel/test_user_logger.py (2)
119-166: Consider setting module and funcName explicitly for consistency.In this test,
LogRecordis created without explicitly settingmoduleandfuncName(line 143-151). While Python derivesmodulefrompathname,funcNamedefaults toNone. For consistency with other tests (e.g., lines 79-80) and clearer test intent, explicitly set these attributes.Apply this diff to improve consistency:
for log_level, expected_severity in severity_mapping.items(): # Reset mock calls for clean test mock_otel_logger.reset_mock() mock_provider.reset_mock() record = logging.LogRecord( name="test", level=log_level, pathname="test.py", lineno=1, msg="Test message", args=(), exc_info=None ) + record.module = "test" + record.funcName = "test_func" # Call emit_to_user_providers emit_to_user_providers(record)
167-209: Consider setting module and funcName explicitly for consistency.Similar to the severity mapping test, this test creates a
LogRecordwithout explicitly settingmoduleandfuncName. While functional, explicitly setting these attributes improves consistency and test clarity.Apply this diff:
record = logging.LogRecord( name="test", level=logging.INFO, pathname="test.py", lineno=1, msg="Test message", args=(), exc_info=None ) + record.module = "test" + record.funcName = "test_func" # Add custom attributes record.custom_attr = "custom_value"
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
tests/otel/test_user_logger.py(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
tests/otel/test_user_logger.py (1)
agentuity/otel/logger.py (6)
add_user_logger_provider(13-24)emit_to_user_providers(27-73)MultiDelegateHandler(76-83)create_logger(86-120)emit(79-83)filter(103-107)
🔇 Additional comments (4)
tests/otel/test_user_logger.py (4)
1-21: LGTM! Proper test isolation with setup/teardown.The imports are appropriate, and the setup/teardown methods correctly clear
_user_logger_providersto ensure test isolation.
211-261: LGTM! Comprehensive coverage of MultiDelegateHandler behavior.The tests correctly verify both scenarios:
- Handler delegates to
emit_to_user_providerswhen providers exist- Handler no-ops when no providers exist
300-336: LGTM! Duplicate prevention logic correctly tested.The test properly verifies that
create_loggerdoesn't add a duplicateMultiDelegateHandlerwhen one already exists:
- Creates logger (1 handler)
- Manually adds another handler to simulate existing handlers (2 handlers)
- Calls
create_loggeragain and verifies it doesn't add a 3rd handler (still 2)The logic correctly addresses the previous review feedback.
362-384: LGTM! Retroactive attachment correctly tested.The test properly verifies retroactive handler attachment:
- Creates logger with handler via
create_logger(registers in_logger_registry)- Removes handler to simulate a logger in old state
- Adds provider, triggering retroactive attachment logic in
add_user_logger_provider- Verifies handler was retroactively attached
The test correctly addresses the previous review feedback.
This implementation mirrors the functionality from the JavaScript SDK PR #176, allowing users to send agent logs to their own OTLP endpoints alongside the default Agentuity telemetry collection.
Amp-Thread-ID: https://ampcode.com/threads/T-1dd20dcd-70fb-4f19-9de2-1984bb3b9a05
Summary by CodeRabbit
New Features
Tests
Chores