Coverage for anaconda_opentelemetry/logging.py: 92%
73 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-01 20:06 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-01 20:06 +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 json
11import logging
12from typing import Dict
14from ._compat import (
15 BatchLogRecordProcessor,
16 ConsoleLogRecordExporter,
17 LoggerProvider,
18 LoggingHandler,
19 get_logger_provider,
20 set_logger_provider,
21)
22from .common import _AnacondaCommon
23from .config import Configuration as Config
24from .attributes import ResourceAttributes as Attributes
25from .exporter_shim import OTLPLogExporterShim
26from .formatting import AttrDict, EventPayload, log_event_name_key
29class EventLogger:
30 """
31 Emits log records purely as OTel log telemetry, bypassing Python's
32 logging hierarchy so they never appear in console/file handlers or
33 interfere with developer log levels. Should be used if the logs
34 being collected are not developer logs, and correspond to events.
35 """
37 def __init__(
38 self,
39 provider: LoggerProvider,
40 logger_name: str = "event_logger",
41 ):
42 self._logger = provider.get_logger(logger_name)
44 def _send_event(
45 self,
46 body: EventPayload,
47 event_name: str,
48 attributes: AttrDict={},
49 ):
50 if not isinstance(body, str):
51 body = json.dumps(body)
52 # update attributes with event name - mandatory for event logs
53 # copy first: `attributes` may be a caller-owned dict (or the default {})
54 attributes = {**attributes, log_event_name_key: event_name}
55 self._logger.emit(body=body, attributes=attributes)
58class _AnacondaLogger(_AnacondaCommon):
59 # Singleton instance (internal only); provide a logger handler for OpenTelemetry log instrumentation
60 _instance = None
62 _default_log_attributes = {log_event_name_key: "__LOG__"}
64 def __init__(self, config: Config, attributes: Attributes):
65 super().__init__(config, attributes)
66 self.log_level = self._get_log_level(config._get_logging_level())
67 self.logger_endpoint = config._get_logging_endpoint()
69 # send_event and get_telemetry_logger_handler emit through self._provider, not the
70 # global one. set_logger_provider ignores a second caller instead of raising, so
71 # check identity to detect that another library got there first.
72 self._provider = LoggerProvider(resource=self.resource, shutdown_on_exit=self._shutdown_on_exit)
73 set_logger_provider(self._provider)
74 if get_logger_provider() is not self._provider:
75 logging.getLogger(__package__).debug(
76 "A global OTel LoggerProvider was already set by another library; "
77 "anaconda_opentelemetry keeps using its own provider for log telemetry."
78 )
79 self._console_exporter: ConsoleLogRecordExporter | None = None
80 # Add OTLP exporter
81 if self.use_console_exporters:
82 exporter = ConsoleLogRecordExporter()
83 self._console_exporter = exporter
84 else:
85 auth_token = config._get_auth_token_logging()
86 headers: Dict[str, str] = {}
87 if auth_token is not None:
88 headers['authorization'] = f'Bearer {auth_token}'
89 if config._get_request_protocol_logging() in ['grpc', 'grpcs']: # gRPC
90 from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter as OTLPLogExportergRPC
91 insecure = not config._get_TLS_logging()
92 exporter = OTLPLogExporterShim(
93 OTLPLogExportergRPC,
94 endpoint=self.logger_endpoint,
95 insecure=insecure,
96 credentials=config._get_ca_cert_logging() if not insecure else None,
97 headers=headers
98 )
99 else: # HTTP
100 from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter as OTLPLogExporterHTTP
101 http_kwargs = self._build_http_exporter_kwargs('logging', self.logger_endpoint, headers)
102 exporter = OTLPLogExporterShim(
103 OTLPLogExporterHTTP,
104 **http_kwargs
105 )
107 self.exporter = exporter
108 self._processor = BatchLogRecordProcessor(self.exporter)
109 self._provider.add_log_record_processor(self._processor)
111 def _get_log_handler(self) -> LoggingHandler:
112 handler = LoggingHandler(level=self.log_level, logger_provider=self._provider)
113 handler.addFilter(self._set_default_attribute_filter())
114 return handler
116 def _set_default_attribute_filter(self) -> logging.Filter:
117 attrs = self._default_log_attributes
118 f = logging.Filter()
119 def _filter(record):
120 for k, v in attrs.items():
121 if not hasattr(record, k):
122 setattr(record, k, v)
123 return True
124 f.filter = _filter
125 return f
127 def _get_event_logger(self, logger_name: str = None) -> EventLogger:
128 if logger_name is None:
129 logger_name = f'{self.service_name}_event_logger'
130 return EventLogger(self._provider, logger_name=logger_name)
132 def _get_log_level(self, str_level: str)-> int:
133 # Convert string from config file to logging level.
134 levels = {
135 "debug": logging.DEBUG,
136 "info": logging.INFO,
137 "warning": logging.WARNING,
138 "warn": logging.WARNING,
139 "error": logging.ERROR,
140 "critical": logging.CRITICAL,
141 "fatal": logging.CRITICAL
142 }
143 return levels.get(str_level.lower(), logging.DEBUG)
145 def _test_set_console_mock(self, new_out): # For testing only...
146 if self._console_exporter is not None and new_out is not None:
147 saved = self._console_exporter.out
148 self._console_exporter.out = new_out
149 return saved
150 return None