Skip to content

fix(tracing): prevent mutation of user options when tracing is enabled #1273

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
420 changes: 397 additions & 23 deletions examples/configs/tracing/README.md

Large diffs are not rendered by default.

146 changes: 146 additions & 0 deletions examples/configs/tracing/working_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2023 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Complete working example of NeMo Guardrails with OpenTelemetry tracing.

This example uses the ConsoleSpanExporter so you can see traces immediately
without needing to set up any external infrastructure.

Usage:
pip install nemoguardrails[tracing] opentelemetry-sdk
python working_example.py
"""

from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter

from nemoguardrails import LLMRails, RailsConfig


def setup_opentelemetry():
"""Configure OpenTelemetry SDK with console output."""

print("Setting up OpenTelemetry...")

# configure resource (metadata about your service)
resource = Resource.create(
{
"service.name": "nemo-guardrails-example",
"service.version": "1.0.0",
"deployment.environment": "development",
},
schema_url="https://opentelemetry.io/schemas/1.26.0",
)

# set up the tracer provider
tracer_provider = TracerProvider(resource=resource)
trace.set_tracer_provider(tracer_provider)

# configure console exporter (prints traces to stdout)
console_exporter = ConsoleSpanExporter()
span_processor = BatchSpanProcessor(console_exporter)
tracer_provider.add_span_processor(span_processor)

print(" OpenTelemetry configured with ConsoleSpanExporter")
print(" Traces will be printed to the console below\n")


def create_guardrails_config():
"""Create a simple guardrails configuration with tracing enabled."""

return RailsConfig.from_content(
colang_content="""
define user express greeting
"hello"
"hi"
"hey"

define flow
user express greeting
bot express greeting

define bot express greeting
"Hello! I'm a guardrails-enabled assistant."
"Hi there! How can I help you today?"
""",
config={
"models": [
{
"type": "main",
"engine": "openai",
"model": "gpt-4o",
}
],
"tracing": {"enabled": True, "adapters": [{"name": "OpenTelemetry"}]},
# Note: The following old-style configuration is deprecated and will be ignored:
# "tracing": {
# "enabled": True,
# "adapters": [{
# "name": "OpenTelemetry",
# "service_name": "my-service", # DEPRECATED - configure in Resource
# "exporter": "console", # DEPRECATED - configure SDK
# "resource_attributes": { # DEPRECATED - configure in Resource
# "env": "production"
# }
# }]
# }
},
)


def main():
"""Main function demonstrating NeMo Guardrails with OpenTelemetry."""
print(" NeMo Guardrails + OpenTelemetry Example")
print("=" * 50)

# step 1: configure OpenTelemetry (APPLICATION'S RESPONSIBILITY)
setup_opentelemetry()

# step 2: create guardrails configuration
print(" Creating guardrails configuration...")
config = create_guardrails_config()
rails = LLMRails(config)
print(" Guardrails configured with tracing enabled\n")

# step 3: test the guardrails with tracing
print(" Testing guardrails (traces will appear below)...")
print("-" * 50)

# this will create spans that get exported to the console
response = rails.generate(
messages=[{"role": "user", "content": "What can you do?"}]
)

print(f"User: What can you do?")
print(f"Bot: {response.response}")
print("-" * 50)

# force export any remaining spans
print("\n Flushing remaining traces...")
trace.get_tracer_provider().force_flush(1000)

print("\n Example completed!")
print("\n Tips:")
print(" - Traces were printed above (look for JSON output)")
print(" - In production, replace ConsoleSpanExporter with OTLP/Jaeger")
print(" - The spans show the internal flow of guardrails processing")


if __name__ == "__main__":
main()
6 changes: 6 additions & 0 deletions nemoguardrails/rails/llm/llmrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,12 @@ async def generate_async(
if self.config.tracing.enabled:
if options is None:
options = GenerationOptions()
else:
# create a copy of the options to avoid modifying the original
options = GenerationOptions(**options.model_dump())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this still work if we pass options in as a Dict:

        options: Optional[Union[dict, GenerationOptions]] = None,

Could you add a test to check this is still ok?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Would options.model_copy(deep=True) be clearer here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the first one is indeed a bug.

Yes it is more readable. I'll make both of these changes 👍🏻


# enable log options
# it is aggressive, but these are required for tracing
if (
not options.log.activated_rails
or not options.log.llm_calls
Expand Down
185 changes: 123 additions & 62 deletions nemoguardrails/tracing/adapters/opentelemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,78 +13,156 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations
"""
OpenTelemetry Adapter for NeMo Guardrails

This adapter follows OpenTelemetry best practices for libraries:
- Uses only the OpenTelemetry API (not SDK)
- Does not modify global state
- Relies on the application to configure the SDK

Usage:
Applications using NeMo Guardrails with OpenTelemetry should configure
the OpenTelemetry SDK before using this adapter:

```python
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

# application configures the SDK
trace.set_tracer_provider(TracerProvider())
tracer_provider = trace.get_tracer_provider()

exporter = OTLPSpanExporter(endpoint="http://localhost:4317")
span_processor = BatchSpanProcessor(exporter)
tracer_provider.add_span_processor(span_processor)

# now NeMo Guardrails can use the configured tracer
config = RailsConfig.from_content(
config={
"tracing": {
"enabled": True,
"adapters": [{"name": "OpenTelemetry"}]
}
}
)
```
"""

from typing import TYPE_CHECKING, Dict, Optional, Type
from __future__ import annotations

from opentelemetry.sdk.trace.export import SpanExporter
import warnings
from importlib.metadata import version
from typing import TYPE_CHECKING, Optional, Type

if TYPE_CHECKING:
from nemoguardrails.tracing import InteractionLog
try:
from opentelemetry import trace
from opentelemetry.sdk.resources import Attributes, Resource
from opentelemetry.sdk.trace import SpanProcessor, TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
from opentelemetry.trace import NoOpTracerProvider

except ImportError:
raise ImportError(
"opentelemetry is not installed. Please install it using `pip install opentelemetry-api opentelemetry-sdk`."
"OpenTelemetry API is not installed. Please install NeMo Guardrails with tracing support: "
"`pip install nemoguardrails[tracing]` or install the API directly: `pip install opentelemetry-api`."
)

from nemoguardrails.tracing.adapters.base import InteractionLogAdapter

# Global dictionary to store registered exporters
_exporter_name_cls_map: Dict[str, Type[SpanExporter]] = {
"console": ConsoleSpanExporter,
}


def register_otel_exporter(name: str, exporter_cls: Type[SpanExporter]):
"""Register a new exporter."""
# DEPRECATED: global dictionary to store registered exporters
# will be removed in v0.16.0
_exporter_name_cls_map: dict[str, Type] = {}


def register_otel_exporter(name: str, exporter_cls: Type):
"""Register a new exporter.

Args:
name: The name to register the exporter under.
exporter_cls: The exporter class to register.

Deprecated:
This function is deprecated and will be removed in version 0.16.0.
Please configure OpenTelemetry exporters directly in your application code.
See the migration guide at:
https://github.com/NVIDIA/NeMo-Guardrails/blob/main/examples/configs/tracing/README.md#migration-guide
"""
warnings.warn(
"register_otel_exporter is deprecated and will be removed in version 0.16.0. "
"Please configure OpenTelemetry exporters directly in your application code. "
"See the migration guide at: "
"https://github.com/NVIDIA/NeMo-Guardrails/blob/develop/examples/configs/tracing/README.md#migration-guide",
DeprecationWarning,
stacklevel=2,
)
_exporter_name_cls_map[name] = exporter_cls


class OpenTelemetryAdapter(InteractionLogAdapter):
"""
OpenTelemetry adapter that follows library best practices.

This adapter uses only the OpenTelemetry API and relies on the application
to configure the SDK. It does not modify global state or create its own
tracer provider.
"""

name = "OpenTelemetry"

def __init__(
self,
service_name="nemo_guardrails_service",
span_processor: Optional[SpanProcessor] = None,
exporter: Optional[str] = None,
exporter_cls: Optional[SpanExporter] = None,
resource_attributes: Optional[Attributes] = None,
service_name: str = "nemo_guardrails",
**kwargs,
):
resource_attributes = resource_attributes or {}
resource = Resource.create(
{"service.name": service_name, **resource_attributes}
)

if exporter_cls and exporter:
raise ValueError(
"Only one of 'exporter' or 'exporter_name' should be provided"
"""
Initialize the OpenTelemetry adapter.

Args:
service_name: Service name for instrumentation scope (not used for resource)
**kwargs: Additional arguments (for backward compatibility)

Note:
Applications must configure the OpenTelemetry SDK before using this adapter.
The adapter will use the globally configured tracer provider.
"""
# check for deprecated parameters and warn users
deprecated_params = [
"exporter",
"exporter_cls",
"resource_attributes",
"span_processor",
]
used_deprecated = [param for param in deprecated_params if param in kwargs]

if used_deprecated:
warnings.warn(
f"OpenTelemetry configuration parameters {used_deprecated} in YAML/config are deprecated "
"and will be ignored. Please configure OpenTelemetry in your application code. "
"See the migration guide at: "
"https://github.com/NVIDIA/NeMo-Guardrails/blob/main/examples/configs/tracing/README.md#migration-guide",
DeprecationWarning,
stacklevel=2,
)
# Set up the tracer provider
provider = TracerProvider(resource=resource)

# Init the span processor and exporter
exporter_cls = None
if exporter:
exporter_cls = self.get_exporter(exporter, **kwargs)

if exporter_cls is None:
exporter_cls = ConsoleSpanExporter()

if span_processor is None:
span_processor = BatchSpanProcessor(exporter_cls)

provider.add_span_processor(span_processor)
trace.set_tracer_provider(provider)
# validate that OpenTelemetry is properly configured
provider = trace.get_tracer_provider()
if provider is None or isinstance(provider, NoOpTracerProvider):
warnings.warn(
"No OpenTelemetry TracerProvider configured. Traces will not be exported. "
"Please configure OpenTelemetry in your application code before using NeMo Guardrails. "
"See setup guide at: "
"https://github.com/NVIDIA/NeMo-Guardrails/blob/main/examples/configs/tracing/README.md#opentelemetry-setup",
UserWarning,
stacklevel=2,
)

self.tracer_provider = provider
self.tracer = trace.get_tracer(__name__)
self.tracer = trace.get_tracer(
service_name,
instrumenting_library_version=version("nemoguardrails"),
schema_url="https://opentelemetry.io/schemas/1.26.0",
)

def transform(self, interaction_log: "InteractionLog"):
"""Transforms the InteractionLog into OpenTelemetry spans."""
Expand Down Expand Up @@ -139,20 +217,3 @@ def _create_span(
span.set_attribute("duration", span_data.duration)

spans[span_data.span_id] = span

@staticmethod
def get_exporter(exporter: str, **kwargs) -> SpanExporter:
if exporter == "zipkin":
try:
from opentelemetry.exporter.zipkin.json import ZipkinExporter

_exporter_name_cls_map["zipkin"] = ZipkinExporter
except ImportError:
raise ImportError(
"The opentelemetry-exporter-zipkin package is not installed. Please install it using 'pip install opentelemetry-exporter-zipkin'."
)

exporter_cls = _exporter_name_cls_map.get(exporter)
if not exporter_cls:
raise ValueError(f"Unknown exporter: {exporter}")
return exporter_cls(**kwargs)
Loading