Skip to content

Commit fe01c49

Browse files
feat: Add OTel SDK setup
1 parent 03cd8ba commit fe01c49

4 files changed

Lines changed: 229 additions & 57 deletions

File tree

Dockerfile.server

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ ARG PYYAML_VERSION=6.0.3
77
ARG GUARDRAILS_PROFILE=opensource
88

99
RUN dnf install -y --nodocs --setopt=install_weak_deps=False \
10-
gcc \
11-
gcc-c++ \
12-
git && \
10+
gcc \
11+
gcc-c++ \
12+
git && \
1313
pip install --no-cache-dir \
14-
pyyaml==${PYYAML_VERSION} && \
14+
pyyaml==${PYYAML_VERSION} && \
1515
dnf clean all && \
1616
rm -rf /var/cache/dnf /tmp/* /var/tmp/*
1717

@@ -20,7 +20,7 @@ COPY nemoguardrails/ ./nemoguardrails/
2020
COPY scripts/ ./scripts/
2121
COPY examples/bots/ ./examples/bots/
2222

23-
RUN chmod +x ./scripts/entrypoint.sh
23+
RUN chmod +x ./scripts/otel/entrypoint.py
2424
RUN python3 ./scripts/filter_guardrails.py ./scripts/provider-list.yaml $GUARDRAILS_PROFILE
2525

2626
ENV HF_HOME=/app/.cache/huggingface \
@@ -32,8 +32,8 @@ ENV HF_HOME=/app/.cache/huggingface \
3232
# Create virtual environment and install dependencies
3333
RUN python3 -m venv /app/.venv && \
3434
/app/.venv/bin/pip install --no-cache-dir \
35-
--extra-index-url https://download.pytorch.org/whl/cpu \
36-
-r requirements.txt && \
35+
--extra-index-url https://download.pytorch.org/whl/cpu \
36+
-r requirements.txt && \
3737
/app/.venv/bin/pip install --no-cache-dir --no-deps . && \
3838
rm -rf /root/.cache/pip /tmp/* /var/tmp/*
3939

@@ -58,7 +58,7 @@ COPY --from=build /app /app
5858
RUN rm -f /etc/security/namespace.conf /usr/lib64/security/pam_namespace.so || true && \
5959
chgrp -R 0 /app && \
6060
chmod -R g=u /app && \
61-
chmod +x /app/scripts/entrypoint.sh
61+
chmod +x /app/scripts/otel/entrypoint.py
6262

6363
USER 1001
6464

@@ -72,4 +72,4 @@ ENV HF_HOME=/app/.cache/huggingface \
7272
PYTHONDONTWRITEBYTECODE=1
7373

7474
EXPOSE 8000
75-
ENTRYPOINT ["./scripts/entrypoint.sh"]
75+
ENTRYPOINT ["./scripts/otel/entrypoint.py"]

scripts/entrypoint.sh

Lines changed: 0 additions & 48 deletions
This file was deleted.

scripts/otel/entrypoint.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/usr/bin/env python
2+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
"""Entrypoint that optionally bootstraps the OTel SDK before starting the server."""
18+
19+
import argparse
20+
import logging
21+
import os
22+
import re
23+
24+
import uvicorn
25+
26+
from scripts.otel.otel import configure_otel_sdk, is_otel_sdk_enabled, make_metrics_app
27+
28+
logging.basicConfig(level=logging.INFO)
29+
30+
_SHUTDOWN = None
31+
if is_otel_sdk_enabled():
32+
_SHUTDOWN = configure_otel_sdk()
33+
34+
try:
35+
parser = argparse.ArgumentParser()
36+
parser.add_argument("--config", default=os.environ.get("CONFIG_DIR", "config"))
37+
parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8000")))
38+
parser.add_argument("--default-config-id", default=os.environ.get("CONFIG_ID"))
39+
parser.add_argument("--verbose", action="store_true")
40+
parser.add_argument("--disable-chat-ui", action="store_true", default=True)
41+
args = parser.parse_args()
42+
43+
from nemoguardrails.server.api import app # noqa: E402
44+
45+
# Validate config ID format
46+
if args.default_config_id and not re.match(r"^[a-zA-Z0-9._-]+$", args.default_config_id):
47+
raise ValueError(f"Invalid config ID: {args.default_config_id}")
48+
app.rails_config_path = os.path.abspath(args.config)
49+
if args.default_config_id:
50+
app.default_config_id = args.default_config_id
51+
app.disable_chat_ui = args.disable_chat_ui
52+
53+
if is_otel_sdk_enabled():
54+
metrics_app = make_metrics_app()
55+
if metrics_app:
56+
app.mount("/metrics/", metrics_app)
57+
58+
log_level = "debug" if args.verbose else "info"
59+
uvicorn.run(app, host="0.0.0.0", port=args.port, log_level=log_level)
60+
finally:
61+
if _SHUTDOWN:
62+
_SHUTDOWN()

scripts/otel/otel.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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

Comments
 (0)