244 lines
8.2 KiB
Python

"""OpenTelemetry span processor and exporter for LangSmith."""
import logging
import warnings
from typing import Any, Optional
from urllib.parse import urljoin
from langsmith import utils as ls_utils
try:
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace.export import BatchSpanProcessor
OTEL_AVAILABLE = True
except ImportError:
warnings.warn(
"OpenTelemetry packages are not installed. "
"Install optional OpenTelemetry dependencies with: "
"pip install langsmith[otel]",
UserWarning,
stacklevel=2,
)
class OTLPSpanExporter: # type: ignore[no-redef]
"""Mock otlp span exporter class."""
def __init__(self, *args, **kwargs):
"""Mock init method."""
raise ImportError(
"OpenTelemetry packages are not installed. "
"Install optional OpenTelemetry dependencies with: "
"pip install langsmith[otel]"
)
class BatchSpanProcessor: # type: ignore[no-redef]
"""Mock batch span processor class."""
def __init__(self, *args, **kwargs):
"""Mock init method."""
raise ImportError(
"OpenTelemetry packages are not installed. "
"Install optional OpenTelemetry dependencies with: "
"pip install langsmith[otel]"
)
class trace:
"""Mock trace class."""
@staticmethod
def get_tracer_provider():
"""Mock get tracer provider method."""
raise ImportError(
"OpenTelemetry packages are not installed. "
"Install optional OpenTelemetry dependencies with: "
"pip install langsmith[otel]"
)
OTEL_AVAILABLE = False
LANGSMITH_METADATA_PREFIX = "langsmith.metadata"
class OtelExporter(OTLPSpanExporter):
"""A subclass of `OTLPSpanExporter` configured for LangSmith.
Environment Variables:
- `LANGSMITH_API_KEY`: Your LangSmith API key.
- `LANGSMITH_ENDPOINT`: Base URL for LangSmith API (defaults to `https://api.smith.langchain.com`).
- `LANGSMITH_PROJECT`: Project identifier.
"""
def __init__(
self,
url: Optional[str] = None,
api_key: Optional[str] = None,
project: Optional[str] = None,
headers: Optional[dict[str, str]] = None,
**kwargs,
):
"""Initialize the `OtelExporter`.
Args:
url: OTLP endpoint URL. Defaults to `{LANGSMITH_ENDPOINT}/otel/v1/traces`.
api_key: LangSmith API key. Defaults to `LANGSMITH_API_KEY` env var.
parent: Parent identifier (e.g., `'project_name:test'`).
Defaults to `LANGSMITH_PARENT` env var.
headers: Additional headers to include in requests.
**kwargs: Additional arguments passed to `OTLPSpanExporter`.
"""
base_url = ls_utils.get_api_url(None)
# Ensure base_url ends with / for proper joining
if not base_url.endswith("/"):
base_url += "/"
endpoint = url or urljoin(base_url, "otel/v1/traces")
api_key = api_key or ls_utils.get_api_key(None)
project = project or ls_utils.get_tracer_project()
headers = headers or {}
if not api_key:
raise ValueError(
"API key is required. Provide it via api_key parameter or "
"LANGSMITH_API_KEY environment variable."
)
if not project:
project = "default"
logging.info(
"No project specified, using default. "
"Configure with LANGSMITH_PROJECT environment variable or "
"project parameter."
)
exporter_headers = {
"x-api-key": api_key,
**headers,
}
if project:
exporter_headers["Langsmith-Project"] = project
self.project = project
super().__init__(endpoint=endpoint, headers=exporter_headers, **kwargs)
class OtelSpanProcessor:
"""A span processor for adding LangSmith to OpenTelemetry setups.
This class combines the `OtelExporter` and `BatchSpanProcessor`
into a single processor that can be added to any `TracerProvider`.
Use this when:
1. You already have OpenTelemetry initialized with other tools
2. You want to add LangSmith alongside existing OTEL exporters
Examples:
# Fresh OpenTelemetry setup (LangSmith only):
from langsmith.integrations.otel import configure
configure(api_key="your-key", project="your-project")
# Add LangSmith to existing OpenTelemetry setup:
from opentelemetry import trace
from langsmith.integrations.otel.processor import OtelSpanProcessor
# Get your existing TracerProvider (already set by other tools)
provider = trace.get_tracer_provider()
# Add LangSmith processor alongside existing processors
langsmith_processor = OtelSpanProcessor(
project="your-project",
)
provider.add_span_processor(langsmith_processor)
"""
def __init__(
self,
api_key: Optional[str] = None,
project: Optional[str] = None,
url: Optional[str] = None,
headers: Optional[dict[str, str]] = None,
SpanProcessor: Optional[type] = None,
):
"""Initialize the `OtelSpanProcessor`.
Args:
api_key: LangSmith API key. Defaults to `LANGSMITH_API_KEY` env var.
project: Project identifier. Defaults to `LANGSMITH_PROJECT` env var.
url: Base URL for LangSmith API. Defaults to `LANGSMITH_ENDPOINT` env var
or `https://api.smith.langchain.com`.
headers: Additional headers to include in requests.
SpanProcessor: Optional span processor class. Defaults to
`BatchSpanProcessor`.
"""
# Create the exporter
# Convert url to the full endpoint URL that OtelExporter expects
exporter_url = None
if url:
exporter_url = f"{url.rstrip('/')}/otel/v1/traces"
self._exporter = OtelExporter(
url=exporter_url, api_key=api_key, project=project, headers=headers
)
# Create the processor chain
if not OTEL_AVAILABLE:
raise ImportError(
"OpenTelemetry packages are not installed. "
"Install optional OpenTelemetry dependencies with: "
"pip install langsmith[otel]"
)
if SpanProcessor is None:
SpanProcessor = BatchSpanProcessor
self._processor = SpanProcessor(self._exporter)
self._metadata: dict[str, Any] = {}
def set_metadata(self, metadata: dict[str, Any]) -> None:
"""Set metadata attributes to propagate to all spans.
Use this to ensure metadata like ``thread_id`` appears on every span
in a trace, not just the root span. This is required for LangSmith
features (e.g. the threads view) that query runs by metadata fields.
"""
self._metadata = metadata.copy()
def on_start(self, span, parent_context=None):
"""Forward span start events to the inner processor."""
if self._metadata:
for key, value in self._metadata.items():
if value is not None:
span.set_attribute(f"{LANGSMITH_METADATA_PREFIX}.{key}", value)
self._processor.on_start(span, parent_context)
def on_end(self, span):
"""Forward span end events to the inner processor."""
self._processor.on_end(span)
def _on_ending(self, span):
"""Forward span ending events to the inner processor."""
if hasattr(self._processor, "_on_ending"):
self._processor._on_ending(span)
def shutdown(self):
"""Shutdown processor."""
self._processor.shutdown()
def force_flush(self, timeout_millis=30000):
"""Force flush the inner processor."""
return self._processor.force_flush(timeout_millis)
@property
def exporter(self):
"""The underlying OtelExporter."""
return self._exporter
@property
def processor(self):
"""The underlying span processor."""
return self._processor