1+ """OpenTelemetry SDK configuration for the NeMo Guardrails server.
2+
3+ When the environment variable ``NEMO_GUARDRAILS_OTEL_SDK=true`` is set, this
4+ module configures the full OTel SDK pipeline on server startup:
5+
6+ - **Traces** → exported via OTLP (gRPC) to the endpoint specified by
7+ ``OTEL_EXPORTER_OTLP_ENDPOINT`` (default ``http://localhost:4317``).
8+ - **Metrics** → exported via Prometheus (``/metrics`` on the server port)
9+ and optionally via OTLP if an endpoint is configured.
10+ - **Logs** → Python ``logging`` records are bridged to the OTel Logs SDK and
11+ exported via OTLP.
12+ """
13+
14+ from __future__ import annotations
15+
16+ import logging
17+ import os
18+ from typing import Callable , Optional
19+
20+ log = logging .getLogger (__name__ )
21+
22+ _OTEL_SDK_ENV_VAR = "NEMO_GUARDRAILS_OTEL_SDK"
23+
24+ class _OTelInternalFilter (logging .Filter ):
25+ _SUPPRESSED = ("opentelemetry" , "grpc" , "urllib3" )
26+ def filter (self , record : logging .LogRecord ) -> bool :
27+ return not any (record .name .startswith (ns ) for ns in self ._SUPPRESSED )
28+
29+ def is_otel_sdk_enabled () -> bool :
30+ """Return True when the built-in OTel SDK setup should be activated."""
31+ return os .getenv (_OTEL_SDK_ENV_VAR , "false" ).lower () in ("true" , "1" , "yes" )
32+
33+
34+ def configure_otel_sdk (
35+ service_name : Optional [str ] = None ,
36+ ) -> Optional [Callable [[], None ]]:
37+ """Configure TracerProvider, MeterProvider, and LoggerProvider.
38+
39+ Returns a ``shutdown()`` callable that flushes and shuts down all
40+ providers, or ``None`` if SDK packages are not installed.
41+ """
42+ try :
43+ from opentelemetry import metrics , trace
44+ from opentelemetry ._logs import set_logger_provider
45+ from opentelemetry .exporter .otlp .proto .grpc ._log_exporter import (
46+ OTLPLogExporter ,
47+ )
48+ from opentelemetry .exporter .otlp .proto .grpc .metric_exporter import (
49+ OTLPMetricExporter ,
50+ )
51+ from opentelemetry .exporter .otlp .proto .grpc .trace_exporter import (
52+ OTLPSpanExporter ,
53+ )
54+ from opentelemetry .sdk ._logs import LoggerProvider , LoggingHandler
55+ from opentelemetry .sdk ._logs .export import BatchLogRecordProcessor
56+ from opentelemetry .sdk .metrics import MeterProvider
57+ from opentelemetry .sdk .metrics .export import PeriodicExportingMetricReader
58+ from opentelemetry .sdk .resources import SERVICE_NAME , Resource
59+ from opentelemetry .sdk .trace import TracerProvider
60+ from opentelemetry .sdk .trace .export import BatchSpanProcessor
61+ except ImportError as exc :
62+ log .warning (
63+ "OTel SDK packages not installed (%s). "
64+ "Install with: pip install nemoguardrails[otel]" ,
65+ exc ,
66+ )
67+ return None
68+
69+ resolved_service_name = (
70+ service_name or os .getenv ("OTEL_SERVICE_NAME" ) or "nemo-guardrails"
71+ )
72+ resource = Resource .create ({SERVICE_NAME : resolved_service_name })
73+
74+ # --- Traces ---
75+ tracer_provider = TracerProvider (resource = resource )
76+ tracer_provider .add_span_processor (BatchSpanProcessor (OTLPSpanExporter ()))
77+ trace .set_tracer_provider (tracer_provider )
78+
79+ # --- Metrics ---
80+ metric_readers = []
81+
82+ try :
83+ from opentelemetry .exporter .prometheus import PrometheusMetricReader
84+
85+ prometheus_reader = PrometheusMetricReader ()
86+ metric_readers .append (prometheus_reader )
87+ except ImportError :
88+ log .info (
89+ "PrometheusMetricReader not installed; "
90+ "Prometheus /metrics endpoint will not be available."
91+ )
92+
93+ otlp_metrics_endpoint = os .getenv ("OTEL_EXPORTER_OTLP_ENDPOINT" ) or os .getenv (
94+ "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"
95+ )
96+ if otlp_metrics_endpoint :
97+ metric_readers .append (
98+ PeriodicExportingMetricReader (OTLPMetricExporter ())
99+ )
100+
101+ meter_provider = MeterProvider (resource = resource , metric_readers = metric_readers )
102+ metrics .set_meter_provider (meter_provider )
103+
104+ # --- Logs ---
105+ # Only enable the OTLP log bridge if the collector supports it.
106+ # Many collectors (e.g. those forwarding only to Tempo) don't have a
107+ # logs pipeline, which causes StatusCode.UNIMPLEMENTED errors.
108+ logger_provider = None
109+ if os .getenv ("NEMO_GUARDRAILS_OTEL_LOGS" , "false" ).lower () in ("true" , "1" , "yes" ):
110+ logger_provider = LoggerProvider (resource = resource )
111+ logger_provider .add_log_record_processor (
112+ BatchLogRecordProcessor (OTLPLogExporter ())
113+ )
114+ set_logger_provider (logger_provider )
115+
116+ handler = LoggingHandler (
117+ level = logging .NOTSET , logger_provider = logger_provider
118+ )
119+ handler .addFilter (_OTelInternalFilter ())
120+ logging .getLogger ().addHandler (handler )
121+
122+ logs_label = "OTLP" if logger_provider else "disabled"
123+ log .info (
124+ "OTel SDK configured: service=%s, traces=OTLP, metrics=%s, logs=%s" ,
125+ resolved_service_name ,
126+ "Prometheus+OTLP" if otlp_metrics_endpoint else "Prometheus" ,
127+ logs_label ,
128+ )
129+
130+ def shutdown () -> None :
131+ providers = [tracer_provider , meter_provider ]
132+ if logger_provider :
133+ providers .append (logger_provider )
134+ for provider in providers :
135+ try :
136+ provider .shutdown ()
137+ except Exception :
138+ log .exception ("Failed to shutdown OTel provider %s" , provider )
139+ log .info ("OTel SDK shutdown complete" )
140+
141+ return shutdown
142+
143+ def make_metrics_app ():
144+ """Return a Prometheus ASGI app for mounting at ``/metrics``, or None.
145+
146+ Uses ``prometheus_client.make_asgi_app()`` which serves the default
147+ registry — the same registry that ``PrometheusMetricReader`` writes to.
148+ """
149+ try :
150+ from prometheus_client import make_asgi_app
151+
152+ return make_asgi_app ()
153+ except ImportError :
154+ log .info (
155+ "prometheus_client not installed; /metrics endpoint unavailable."
156+ )
157+ return None
158+
0 commit comments