Coverage for anaconda_opentelemetry/signals.py: 92%
225 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# signals.py
6"""
7Anaconda Telemetry - Metrics Module
9This module provides functionality for logging, metrics, and tracing (together called
10signals) using OpenTelemetry. It includes classes for handling logging, metrics, and
11tracing, as well as functions for initializing the telemetry system and recording metrics.
12"""
14import logging, socket, threading
15from typing import Dict, Iterator, List, Optional
16from contextlib import contextmanager
18from opentelemetry import trace, metrics, _logs
19from opentelemetry.sdk.trace import TracerProvider
20from opentelemetry.sdk.metrics import MeterProvider
21from opentelemetry.sdk._logs import LoggingHandler, LoggerProvider
23from .config import Configuration as Config
24from .attributes import ResourceAttributes as Attributes
25from .formatting import AttrDict
27from .common import _AnacondaCommon, MetricsNotInitialized
28from .logging import _AnacondaLogger
29from .metrics import _AnacondaMetrics
30from .tracing import _AnacondaTrace, ASpan, _ASpan
33_SUPPRESSED_LOGGER_ROOTS = ('opentelemetry',)
36def _suppress_otel_export_errors():
37 for root in _SUPPRESSED_LOGGER_ROOTS:
38 logging.getLogger(root).setLevel(logging.CRITICAL)
39 prefix = root + '.'
40 for name, logger in list(logging.Logger.manager.loggerDict.items()):
41 if name.startswith(prefix) and isinstance(logger, logging.Logger):
42 logger.setLevel(logging.NOTSET)
45# Internet and endpoint access check method
46def __check_internet_status(config: Config, timeout: float = 5.0) -> tuple[bool,bool]: # seconds max to pause....
47 # Relies on Configuration to validate the endpoint...
48 internet = True
49 access = True
50 if config._get_skip_internet_check():
51 return True, True
52 endpoint = config._get_default_endpoint()
53 try:
54 # Access to a highly available DNS site...
55 socket.create_connection(('8.8.8.8', 53), timeout=timeout / 2).close()
56 except OSError:
57 logging.getLogger(__package__).warning("Anaconda OpenTelemetry: No Internet was detected!")
58 internet = False # No internet, but internet is not an absolute requirement for on-prem solutions.
59 try:
60 socket.create_connection((config._endpoints['default_endpoint'].host, config._endpoints['default_endpoint']._internet_check_port), timeout=timeout / 2).close()
61 except OSError:
62 logging.getLogger(__package__).fatal(f"Anaconda OpenTelemetry: No access to the endpoint '{endpoint}'!")
63 access = False # This could be fatal, not endpoint for telemetry.
64 if access == True:
65 logging.getLogger(__package__).info(f"Anaconda OpenTelemetry: Successful access to the endpoint '{endpoint}'!")
66 return internet, access
68__ANACONDA_TELEMETRY_INITIALIZED = False
69__SIGNALS = None
70__CONFIG = None
73################################################################################
74# Exposed APIs
75def initialize_telemetry(config: Config,
76 attributes: Attributes = None,
77 signal_types: List[str] = ['metrics']):
78 """
79 Initializes the telemetry system.
81 Args:
82 service_name (str): The name of the service.
83 service_version (str): The version of the service.
84 config (Configuration): The configuration for the telemetry. At a minimum, the Configuration must have a default endpoint
85 for connection to the collector.
86 attributes (ResourceAttributes, optional): A class containing common attributes. If provided,
87 it will override any values shared with configuration file.
88 signal_types (list, optional): List of metric types to initialize. Defaults to ['logging','metrics','tracing'].
89 Supported values are 'logging', 'metrics', and 'tracing'. If an empty list is provided, no metrics will be initialized.
91 Raises:
92 ValueError: If the config passed is None or the attributes passed are None.
93 """
94 global __ANACONDA_TELEMETRY_INITIALIZED
95 global __SIGNALS
96 global __CONFIG
98 if __ANACONDA_TELEMETRY_INITIALIZED is True:
99 return # Already initialized
100 if config is None:
101 raise ValueError(f"The config argument is required but was None")
102 if attributes is None:
103 raise ValueError(f"The attributes argument is required but was None")
105 __CONFIG = config
106 __SIGNALS = signal_types
108 # Check ResourceAttributes object
109 if attributes is None:
110 raise ValueError(f"The attributes argument is required but was None")
111 elif type(attributes.parameters) != dict:
112 raise ValueError(f"The parameters attribute in ResourceAttributes must be a dictionary")
114 if not config._get_verbose_export_errors():
115 _suppress_otel_export_errors()
117 # Right now, no action is taken but it possible to disable telemetry with no access to the endpoint...
118 _, _ = __check_internet_status(config, timeout=4) # Max wait 4 seconds...
120 # all params are the same currently so only write them once
121 init_params = (config, attributes)
123 # Initialize logging here...
124 signal_type_count = 0
125 if 'logging' in signal_types:
126 _AnacondaLogger._instance = _AnacondaLogger(*init_params)
127 signal_type_count += 1
129 # Initialize the telemetry system here
130 if 'metrics' in signal_types:
131 _AnacondaMetrics._instance = _AnacondaMetrics(*init_params)
132 signal_type_count += 1
134 # Initialize tracing here...
135 if 'tracing' in signal_types:
136 _AnacondaTrace._instance = _AnacondaTrace(*init_params)
137 signal_type_count += 1
139 if signal_type_count == 0:
140 logging.getLogger(__package__).warning(
141 "No signal types were initialized. Was this intended? If not please check the " +
142 "'metrics' section in the configuration file and/or the list of " +
143 "metric types in the parameter 'signal_types'."
144 )
145 __ANACONDA_TELEMETRY_INITIALIZED = True
147_SHUTDOWN_DONE = False
148_shutdown_lock = threading.Lock()
151def flush_telemetry() -> bool:
152 """Force-flush all initialized telemetry providers.
154 Uses the standard OTel global getters to retrieve providers.
155 Returns True if all providers flushed successfully.
156 """
157 if not __ANACONDA_TELEMETRY_INITIALIZED:
158 return False
159 success = True
160 try:
161 tp = trace.get_tracer_provider()
162 if isinstance(tp, TracerProvider):
163 try:
164 tp.force_flush()
165 except Exception:
166 logging.getLogger(__package__).debug("Tracer flush failed", exc_info=True)
167 success = False
169 mp = metrics.get_meter_provider()
170 if isinstance(mp, MeterProvider):
171 try:
172 mp.force_flush()
173 except Exception:
174 logging.getLogger(__package__).debug("Meter flush failed", exc_info=True)
175 success = False
177 lp = _logs.get_logger_provider()
178 if isinstance(lp, LoggerProvider):
179 try:
180 lp.force_flush()
181 except Exception:
182 logging.getLogger(__package__).debug("Logger flush failed", exc_info=True)
183 success = False
184 except Exception:
185 logging.getLogger(__package__).debug("flush_telemetry failed", exc_info=True)
186 success = False
187 return success
190def shutdown_telemetry(*, timeout_seconds: Optional[float] = None) -> bool:
191 """Flush all telemetry providers at process shutdown, optionally time-bounded.
193 Performs a bounded *force-flush* (via :func:`flush_telemetry`). It intentionally does
194 not call ``provider.shutdown()``: at process exit that only adds worker-thread joins
195 (more blocking) with no benefit. Pair with
196 ``config.set_shutdown_on_exit(False)`` to control flush timing from a
197 signal handler or atexit path.
199 With ``timeout_seconds=None`` the flush runs synchronously (unbounded). When set, the
200 flush runs on a daemon thread joined for at most ``timeout_seconds``; only the
201 caller's wait is bounded (a still-running flush thread is reaped at interpreter exit).
203 Idempotent and thread-safe: once a flush completes it is not repeated; concurrent or
204 re-entrant (signal-handler) calls never double-flush or block. A call that times out
205 does not mark completion, so a later call may retry.
207 The ``join`` blocks the calling thread, so do not call this with a ``timeout_seconds``
208 from inside an async event loop; use it from a signal handler, an atexit path, or a
209 dedicated thread.
211 Returns True if the flush completed (now or previously); False if telemetry was never
212 initialized, the flush timed out, or another call is already in progress.
213 """
214 global _SHUTDOWN_DONE
215 if not __ANACONDA_TELEMETRY_INITIALIZED:
216 return False
217 if _SHUTDOWN_DONE:
218 return True
219 # Non-blocking so a concurrent or re-entrant caller returns immediately instead of
220 # double-flushing or deadlocking (this may run inside a signal handler).
221 if not _shutdown_lock.acquire(blocking=False):
222 return _SHUTDOWN_DONE
223 try:
224 if _SHUTDOWN_DONE:
225 return True
226 if timeout_seconds is None:
227 completed = flush_telemetry()
228 else:
229 flush_thread = threading.Thread(target=flush_telemetry, daemon=True)
230 flush_thread.start()
231 flush_thread.join(timeout=timeout_seconds)
232 completed = not flush_thread.is_alive()
233 if completed:
234 _SHUTDOWN_DONE = True
235 return completed
236 finally:
237 _shutdown_lock.release()
240def change_signal_endpoint(signal_type: str,
241 new_endpoint: str,
242 auth_token: str = None):
243 """
244 Updates the endpoint for the passed signal
246 Args:
247 signal_type (str): signal type to update endpoint for. Supported values are 'logging', 'metrics', and 'tracing'
249 Returns:
250 boolean: value indicating whether the update was successful or not
251 """
252 if signal_type.lower() == 'metrics':
253 _AnacondaTelInstance = _AnacondaMetrics
254 batch_access = _AnacondaTelInstance._instance.metric_reader
255 elif signal_type.lower() == 'tracing':
256 _AnacondaTelInstance = _AnacondaTrace
257 batch_access = _AnacondaTelInstance._instance._processor
258 elif signal_type.lower() == 'logging':
259 _AnacondaTelInstance = _AnacondaLogger
260 batch_access = _AnacondaTelInstance._instance._processor
261 else:
262 logging.getLogger(__package__).warning(f"{signal_type} not a valid signal type.")
263 return False
265 # execute OpenTelemetry changes
266 updated_endpoint = _AnacondaTelInstance._instance.exporter.change_signal_endpoint(
267 batch_access,
268 _AnacondaTelInstance._instance._config,
269 new_endpoint,
270 auth_token=auth_token
271 )
273 if not updated_endpoint:
274 logging.getLogger(__package__).warning(f"Endpoint for {signal_type} failed to update.")
275 return False
276 else:
277 logging.getLogger(__package__).info(f"Endpoint for {signal_type} was successfully updated.")
278 return True
280def record_histogram(metric_name, value, attributes: AttrDict={}) -> bool:
281 """
282 Records a increasing only metric with the given name and value. The value will
283 always appear in the attributes section in the raw OTLP output and the timestamp
284 will be the histogram value.
286 Will catch any exceptions generated by metric usage.
288 Args:
289 metric_name (str): The name of the metric.
290 value (float): The value of the metric. Can be any float since the timestamp is the ever increasing value of the histogram.
291 attributes (dict, optional): Additional attributes for the metric. Defaults to {}.
293 Returns:
294 bool: True if the metric was recorded successfully, False otherwise (logging the error).
295 """
296 if __ANACONDA_TELEMETRY_INITIALIZED is False:
297 logging.getLogger(__package__).error("Anaconda telemetry system not initialized.") # Since init didn't happen this is not exported in OTel!!!
298 return False
299 try:
300 return _AnacondaMetrics._instance.record_histogram(metric_name, value, _AnacondaMetrics._instance._process_attributes(attributes))
301 except MetricsNotInitialized as me:
302 logging.getLogger(__package__).warning(f"An attempt was made to record a histogram metric when metrics were not configured.")
303 return False
304 except Exception as e:
305 logging.getLogger(__package__).error(f"UNCAUGHT EXCEPTION:\n{e}")
306 return False
308def set_gauge(metric_name, value, attributes: AttrDict={}) -> bool:
309 """
310 Sets a gauge metric with the given name to the given value. A gauge records the last
311 value set rather than a sum, so it is the right choice for values that go up and down
312 and are sampled rather than accumulated (queue depth, memory in use, temperature).
314 Will catch any exceptions generated by metric usage.
316 Args:
317 metric_name (str): The name of the metric.
318 value (int | float): The current value of the metric. Must be an int or a float; negative
319 values are allowed. A value of any other type (including None and bool) is rejected.
320 attributes (dict, optional): Additional attributes for the metric. Defaults to {}.
322 Returns:
323 bool: True if the metric was recorded successfully, False otherwise (logging the error).
324 """
325 if __ANACONDA_TELEMETRY_INITIALIZED is False:
326 logging.getLogger(__package__).error("Anaconda telemetry system not initialized.") # Since init didn't happen this is not exported in OTel!!!
327 return False
328 try:
329 return _AnacondaMetrics._instance.set_gauge(metric_name, value, _AnacondaMetrics._instance._process_attributes(attributes))
330 except MetricsNotInitialized:
331 logging.getLogger(__package__).warning("An attempt was made to set a gauge metric when metrics were not configured.")
332 return False
333 except Exception as e:
334 logging.getLogger(__package__).error(f"UNCAUGHT EXCEPTION:\n{e}")
335 return False
337def increment_counter(counter_name, by=1, attributes: AttrDict={}) -> bool:
338 """
339 Increments a counter or up down counter by the given parameter 'by'.
341 Will catch any exceptions generated by metric usage.
343 Args:
344 counter_name (str): The name of the counter.
345 by (int, optional): The value to increment by. Defaults to 1. The abs(by) is used to protect from negative numbers.
346 attributes (dict, optional): Additional attributes for the counter. Defaults to {}.
348 Returns:
349 bool: True if the counter was incremented successfully, False otherwise (logging the error).
350 """
351 if __ANACONDA_TELEMETRY_INITIALIZED is False:
352 logging.getLogger(__package__).error("Anaconda telemetry system not initialized.") # Since init didn't happen this is not exported in OTel!!!
353 return False
354 try:
355 return _AnacondaMetrics._instance.increment_counter(counter_name, by, _AnacondaMetrics._instance._process_attributes(attributes))
356 except MetricsNotInitialized:
357 logging.getLogger(__package__).warning(f"An attempt was made to change/create a counter metric when metrics were not configured.")
358 return False
359 except Exception as e:
360 logging.getLogger(__package__).error(f"UNCAUGHT EXCEPTION:\n{e}")
361 return False
363def decrement_counter(counter_name, by=1, attributes: AttrDict={}) -> bool:
364 """
365 Decrements a up down counter with the given name and value. If applied to a regular counter it will log a warning and silently fail.
367 Will catch any exceptions generated by metric usage.
369 Args:
370 counter_name (str): The name of the counter.
371 by (int, optional): The value to decrement by. Defaults to 1. abs(by) is used to protect from negative numbers.
372 attributes (dict, optional): Additional attributes for the counter. Defaults to {}.
374 Returns:
375 bool: True if the counter was decremented successfully, False otherwise (logging the error).
376 """
377 if __ANACONDA_TELEMETRY_INITIALIZED is False:
378 logging.getLogger(__package__).error("Anaconda telemetry system not initialized.") # Since init didn't happen this is not exported in OTel!!!
379 return False
380 try:
381 return _AnacondaMetrics._instance.decrement_counter(counter_name, by, _AnacondaMetrics._instance._process_attributes(attributes))
382 except MetricsNotInitialized:
383 logging.getLogger(__package__).warning(f"An attempt was made to change/create a counter metric when metrics were not configured.")
384 return False
385 except Exception as e:
386 logging.getLogger(__package__).error(f"UNCAUGHT EXCEPTION:\n{e}")
387 return False
389@contextmanager
390def get_trace(name: str, attributes: AttrDict = {}, carrier: Dict[str,str] = None) -> Iterator[_ASpan]:
391 """
392 Create or continue a named trace (based on the 'carrier' parameter).
394 Use the function like a Python I/O object (keyword 'with') to ensure the span is closed properly.
396 Will catch any exceptions generated by tracing usage.
398 Args:
399 name (str): The name of the trace.
400 attributes (dict, optional): Additional attributes for the trace. Defaults to {}.
401 carrier (dict, optional): The carrier used to continue a trace context in the output data. Defaults to None.
403 Example:
404 with get_trace("my_trace_name", {"key": "value"}) as span:
405 # Do some work here
406 pass
407 # The span will be closed automatically when exiting the 'with' block.
409 Returns:
410 Iterator[Tracer]: An iterator for the tracer.
411 """
412 if __ANACONDA_TELEMETRY_INITIALIZED is False:
413 logging.getLogger(__package__).error("Anaconda telemetry system not initialized.") # Since init didn't happen this is not exported in OTel!!!
414 return None
416 try:
417 aspan = _AnacondaTrace._instance.get_span(name, _AnacondaTrace._instance._process_attributes(attributes), carrier)
418 except: # Trace is different than the other signals, there is no easy way to log and continue.
419 logging.getLogger(__package__).warning(f"Attempt to trace a with-block when tracing was not configured.")
420 aspan = _ASpan("UNKNOWN", span=None, noop=True)
421 try:
422 yield aspan
423 except Exception as e:
424 aspan.add_exception(e)
425 aspan.set_error_status()
426 _AnacondaTrace._instance.logger.error(f"Error in trace span {name}: {e}")
427 finally:
428 aspan._close()
430def get_telemetry_logger_handler() -> LoggingHandler:
431 """
432 Returns the telemetry logger handler. This lets the package user control how the application uses the telemetry logger.
433 Insert this handler into your named logger.
435 log = logging.getLogger("my_logger")
436 log.addHandler(get_telemetry_logger_handler())
438 Previously, this was injected into the root logger, but this turned out to be problematic for some applications that
439 wanted to control the logging configuration more precisely. This injection behavior is now disabled. If you wish to
440 inject the handler into the root logger, you can do so manually. See the Python logging documentation for more information.
442 Returns:
443 logging.Logger: The telemetry logger handler if logging was enabled via signal_types in initialize_telemetry,
444 otherwise this function returns None.
445 Raises:
446 RuntimeError: if `initialize_telemetry` has not been called
447 """
448 global __ANACONDA_TELEMETRY_INITIALIZED
449 if __ANACONDA_TELEMETRY_INITIALIZED is False:
450 logging.getLogger(__package__).error("Anaconda telemetry system not initialized.") # Since init didn't happen this is not exported in OTel!!!
451 raise RuntimeError("Anaconda telemetry system not initialized.")
452 if _AnacondaLogger._instance is not None:
453 return _AnacondaLogger._instance._get_log_handler()
454 return None # No logger handler available, logging not initialized or not configured.
456def send_event(body: str, event_name: str, attributes: AttrDict={}) -> bool:
457 """
458 Sends a log event directly to the OpenTelemetry pipeline without using Python's logging module.
459 This is useful when you want to export log telemetry but don't want the output mixing with
460 your application's output or developer logs.
462 Params:
463 body (str): the log message body
464 event_name (str): mandatory event name added to attributes
465 attributes (AttrDict): optional attributes dict
466 Returns:
467 bool: True if the event was sent, False if logging was not initialized
468 Raises:
469 RuntimeError: if `initialize_telemetry` has not been called
470 """
471 global __ANACONDA_TELEMETRY_INITIALIZED
472 if __ANACONDA_TELEMETRY_INITIALIZED is False:
473 logging.getLogger(__package__).error("Anaconda telemetry system not initialized.")
474 raise RuntimeError("Anaconda telemetry system not initialized.")
475 if _AnacondaLogger._instance is not None:
476 event_logger = _AnacondaLogger._instance._get_event_logger()
477 event_logger._send_event(body, event_name, _AnacondaLogger._instance._process_attributes(attributes))
478 return True
479 return False