Coverage for anaconda_opentelemetry/logging.py: 89%
83 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-11 20:51 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-11 20:51 +0000
1# -*- coding: utf-8 -*-
2# SPDX-FileCopyrightText: 2025 Anaconda, Inc
3# SPDX-License-Identifier: Apache-2.0
5# logging.py
6"""
7Anaconda Telemetry - Logging signal class and EventLogger.
8"""
10import inspect
11import json
12import logging
13from time import time_ns
14from typing import Dict
16from opentelemetry import _logs
17from opentelemetry.sdk._logs import Logger, LoggerProvider, LoggingHandler
18from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter
20try: # opentelemetry-sdk >= 1.40.0 removed LogRecord from the public SDK namespace
21 from opentelemetry.sdk._logs import LogRecord
22except ImportError: # pragma: no cover - depends on installed SDK version
23 LogRecord = None
25from .common import _AnacondaCommon
26from .config import Configuration as Config
27from .attributes import ResourceAttributes as Attributes
28from .exporter_shim import OTLPLogExporterShim
29from .formatting import AttrDict, EventPayload, log_event_name_key
31# ``Logger.emit`` gained keyword arguments (body=, attributes=, ...) in
32# opentelemetry-sdk 1.40.0; before that it took a single positional LogRecord.
33# This package pins ==1.40.0, but an older SDK already present in the
34# environment wins at import time, so detect the shape instead of assuming it.
35_EMIT_TAKES_KWARGS = 'body' in inspect.signature(Logger.emit).parameters
38class EventLogger:
39 """
40 Emits log records purely as OTel log telemetry, bypassing Python's
41 logging hierarchy so they never appear in console/file handlers or
42 interfere with developer log levels. Should be used if the logs
43 being collected are not developer logs, and correspond to events.
44 """
46 def __init__(
47 self,
48 provider: LoggerProvider,
49 logger_name: str = "event_logger",
50 ):
51 self._logger = provider.get_logger(logger_name)
53 def _send_event(
54 self,
55 body: EventPayload,
56 event_name: str,
57 attributes: AttrDict={},
58 ):
59 if not isinstance(body, str):
60 body = json.dumps(body)
61 # update attributes with event name - mandatory for event logs
62 # copy first: `attributes` may be a caller-owned dict (or the default {})
63 attributes = {**attributes, log_event_name_key: event_name}
64 if _EMIT_TAKES_KWARGS:
65 self._logger.emit(body=body, attributes=attributes)
66 else:
67 # opentelemetry-sdk < 1.40.0: emit() takes a single LogRecord.
68 self._logger.emit(LogRecord(
69 body=body,
70 attributes=attributes,
71 observed_timestamp=time_ns(),
72 ))
75class _AnacondaLogger(_AnacondaCommon):
76 # Singleton instance (internal only); provide a logger handler for OpenTelemetry log instrumentation
77 _instance = None
79 _default_log_attributes = {log_event_name_key: "__LOG__"}
81 def __init__(self, config: Config, attributes: Attributes):
82 super().__init__(config, attributes)
83 self.log_level = self._get_log_level(config._get_logging_level())
84 self.logger_endpoint = config._get_logging_endpoint()
86 # Create logger provider
87 self._provider = LoggerProvider(resource=self.resource, shutdown_on_exit=self._shutdown_on_exit)
88 try:
89 _logs.set_logger_provider(self._provider)
90 except Exception:
91 self.logger.warning("The logger provider was previously set; this call is ignored.")
92 self._console_exporter: ConsoleLogExporter | None = None
93 # Add OTLP exporter
94 if self.use_console_exporters:
95 exporter = ConsoleLogExporter()
96 self._console_exporter = exporter
97 else:
98 auth_token = config._get_auth_token_logging()
99 headers: Dict[str, str] = {}
100 if auth_token is not None:
101 headers['authorization'] = f'Bearer {auth_token}'
102 if config._get_request_protocol_logging() in ['grpc', 'grpcs']: # gRPC
103 from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter as OTLPLogExportergRPC
104 insecure = not config._get_TLS_logging()
105 exporter = OTLPLogExporterShim(
106 OTLPLogExportergRPC,
107 endpoint=self.logger_endpoint,
108 insecure=insecure,
109 credentials=config._get_ca_cert_logging() if not insecure else None,
110 headers=headers
111 )
112 else: # HTTP
113 from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter as OTLPLogExporterHTTP
114 http_kwargs = self._build_http_exporter_kwargs('logging', self.logger_endpoint, headers)
115 exporter = OTLPLogExporterShim(
116 OTLPLogExporterHTTP,
117 **http_kwargs
118 )
120 self.exporter = exporter
121 self._processor = BatchLogRecordProcessor(self.exporter)
122 self._provider.add_log_record_processor(self._processor)
124 def _get_log_handler(self) -> LoggingHandler:
125 handler = LoggingHandler(level=self.log_level, logger_provider=self._provider)
126 handler.addFilter(self._set_default_attribute_filter())
127 return handler
129 def _set_default_attribute_filter(self) -> logging.Filter:
130 attrs = self._default_log_attributes
131 f = logging.Filter()
132 def _filter(record):
133 for k, v in attrs.items():
134 if not hasattr(record, k):
135 setattr(record, k, v)
136 return True
137 f.filter = _filter
138 return f
140 def _get_event_logger(self, logger_name: str = None) -> EventLogger:
141 if logger_name is None:
142 logger_name = f'{self.service_name}_event_logger'
143 return EventLogger(self._provider, logger_name=logger_name)
145 def _get_log_level(self, str_level: str)-> int:
146 # Convert string from config file to logging level.
147 levels = {
148 "debug": logging.DEBUG,
149 "info": logging.INFO,
150 "warning": logging.WARNING,
151 "warn": logging.WARNING,
152 "error": logging.ERROR,
153 "critical": logging.CRITICAL,
154 "fatal": logging.CRITICAL
155 }
156 return levels.get(str_level.lower(), logging.DEBUG)
158 def _test_set_console_mock(self, new_out): # For testing only...
159 if self._console_exporter is not None and new_out is not None:
160 saved = self._console_exporter.out
161 self._console_exporter.out = new_out
162 return saved
163 return None