initial commit

This commit is contained in:
2026-05-11 12:36:20 +05:30
commit 384cbe8019
15377 changed files with 2360544 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
"""Client configuration for OpenTelemetry integration with LangSmith."""
import os
import warnings
from typing import TYPE_CHECKING
if TYPE_CHECKING:
try:
from opentelemetry.sdk.trace import TracerProvider # type: ignore[import]
except ImportError:
TracerProvider = object # type: ignore[assignment, misc]
from langsmith import utils as ls_utils
def _import_otel_client():
"""Dynamically import OTEL client modules when needed."""
try:
from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( # type: ignore[import]
OTLPSpanExporter,
)
from opentelemetry.sdk.resources import ( # type: ignore[import]
SERVICE_NAME,
Resource,
)
from opentelemetry.sdk.trace import TracerProvider # type: ignore[import]
from opentelemetry.sdk.trace.export import ( # type: ignore[import]
BatchSpanProcessor,
)
return (
OTLPSpanExporter,
SERVICE_NAME,
Resource,
TracerProvider,
BatchSpanProcessor,
)
except ImportError as e:
warnings.warn(
f"OTEL_ENABLED is set but OpenTelemetry packages are not installed: {e}"
)
return None
def get_otlp_tracer_provider() -> "TracerProvider":
"""Get the OTLP tracer provider for LangSmith.
This function creates a tracer provider that exports spans using the OTLP protocol
with LangSmith-specific defaults:
- OTEL_EXPORTER_OTLP_ENDPOINT: https://api.smith.langchain.com/otel
- OTEL_EXPORTER_OTLP_HEADERS: Contains x-api-key from LangSmith API key and
Langsmith-Project header if project is configured
These defaults can be overridden by setting the environment variables before
calling this function.
Returns:
TracerProvider: The OTLP tracer provider.
"""
# Import OTEL modules dynamically
otel_imports = _import_otel_client()
if otel_imports is None:
raise ImportError(
"OpenTelemetry packages are required to use this function. "
"Please install with `pip install langsmith[otel]`"
)
(
OTLPSpanExporter,
SERVICE_NAME,
Resource,
TracerProvider,
BatchSpanProcessor,
) = otel_imports
if "OTEL_EXPORTER_OTLP_ENDPOINT" not in os.environ:
ls_endpoint = ls_utils.get_api_url(None)
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = f"{ls_endpoint}/otel"
# Configure headers with API key and project if available
if "OTEL_EXPORTER_OTLP_HEADERS" not in os.environ:
api_key = ls_utils.get_api_key(None)
headers = f"x-api-key={api_key}"
project = ls_utils.get_tracer_project()
if project:
headers += f",Langsmith-Project={project}"
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = headers
service_name = os.environ.get("OTEL_SERVICE_NAME", "langsmith")
resource = Resource(
attributes={
SERVICE_NAME: service_name,
# Marker to identify LangSmith's internal provider
"langsmith.internal_provider": True,
}
)
tracer_provider = TracerProvider(resource=resource)
otlp_exporter = OTLPSpanExporter()
span_processor = BatchSpanProcessor(otlp_exporter)
tracer_provider.add_span_processor(span_processor)
return tracer_provider

View File

@@ -0,0 +1,845 @@
"""OpenTelemetry exporter for LangSmith runs."""
from __future__ import annotations
import datetime
import logging
import time
import uuid
import warnings
from typing import TYPE_CHECKING, Any, Optional
if TYPE_CHECKING:
try:
from opentelemetry.context.context import Context # type: ignore[import]
from opentelemetry.trace import Span # type: ignore[import]
except ImportError:
Context = Any # type: ignore[assignment, misc]
Span = Any # type: ignore[assignment, misc]
from langsmith import utils as ls_utils
from langsmith._internal import _orjson
from langsmith._internal._operations import (
SerializedRunOperation,
)
from langsmith._internal._otel_utils import (
get_otel_span_id_from_uuid,
get_otel_trace_id_from_uuid,
)
def _import_otel_exporter():
"""Dynamically import OTEL exporter modules when needed."""
try:
from opentelemetry import trace # type: ignore[import]
from opentelemetry.context.context import Context # type: ignore[import]
from opentelemetry.trace import ( # type: ignore[import]
NonRecordingSpan,
Span,
SpanContext,
TraceFlags,
TraceState,
set_span_in_context,
)
return (
trace,
Context,
NonRecordingSpan,
Span,
SpanContext,
TraceFlags,
TraceState,
set_span_in_context,
)
except ImportError as e:
warnings.warn(
f"OTEL_ENABLED is set but OpenTelemetry packages are not installed: {e}"
)
return None
logger = logging.getLogger(__name__)
# OpenTelemetry GenAI semconv attribute names
GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
GEN_AI_SYSTEM = "gen_ai.system"
GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"
GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
GEN_AI_USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens"
GEN_AI_REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens"
GEN_AI_REQUEST_TEMPERATURE = "gen_ai.request.temperature"
GEN_AI_REQUEST_TOP_P = "gen_ai.request.top_p"
GEN_AI_REQUEST_FREQUENCY_PENALTY = "gen_ai.request.frequency_penalty"
GEN_AI_REQUEST_PRESENCE_PENALTY = "gen_ai.request.presence_penalty"
GEN_AI_RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons"
GENAI_PROMPT = "gen_ai.prompt"
GENAI_COMPLETION = "gen_ai.completion"
GEN_AI_REQUEST_EXTRA_QUERY = "gen_ai.request.extra_query"
GEN_AI_REQUEST_EXTRA_BODY = "gen_ai.request.extra_body"
GEN_AI_SERIALIZED_NAME = "gen_ai.serialized.name"
GEN_AI_SERIALIZED_SIGNATURE = "gen_ai.serialized.signature"
GEN_AI_SERIALIZED_DOC = "gen_ai.serialized.doc"
GEN_AI_RESPONSE_ID = "gen_ai.response.id"
GEN_AI_RESPONSE_SERVICE_TIER = "gen_ai.response.service_tier"
GEN_AI_RESPONSE_SYSTEM_FINGERPRINT = "gen_ai.response.system_fingerprint"
GEN_AI_USAGE_INPUT_TOKEN_DETAILS = "gen_ai.usage.input_token_details"
GEN_AI_USAGE_OUTPUT_TOKEN_DETAILS = "gen_ai.usage.output_token_details"
# LangSmith custom attributes
LANGSMITH_SESSION_ID = "langsmith.trace.session_id"
LANGSMITH_SESSION_NAME = "langsmith.trace.session_name"
LANGSMITH_RUN_TYPE = "langsmith.span.kind"
LANGSMITH_NAME = "langsmith.trace.name"
LANGSMITH_METADATA = "langsmith.metadata"
LANGSMITH_TAGS = "langsmith.span.tags"
LANGSMITH_RUNTIME = "langsmith.span.runtime"
LANGSMITH_REQUEST_STREAMING = "langsmith.request.streaming"
LANGSMITH_REQUEST_HEADERS = "langsmith.request.headers"
# GenAI event names
GEN_AI_SYSTEM_MESSAGE = "gen_ai.system.message"
GEN_AI_USER_MESSAGE = "gen_ai.user.message"
GEN_AI_ASSISTANT_MESSAGE = "gen_ai.assistant.message"
GEN_AI_CHOICE = "gen_ai.choice"
WELL_KNOWN_OPERATION_NAMES = {
"llm": "chat",
"tool": "execute_tool",
"retriever": "embeddings",
"embedding": "embeddings",
"prompt": "chat",
}
def _get_operation_name(run_type: str) -> str:
return WELL_KNOWN_OPERATION_NAMES.get(run_type, run_type)
class OTELExporter:
__slots__ = [
"_tracer",
"_span_info",
"_otel_available",
"_trace",
"_span_ttl_seconds",
"_last_cleanup",
]
"""OpenTelemetry exporter for LangSmith runs."""
def __init__(self, tracer_provider=None, span_ttl_seconds=None):
"""Initialize the OTEL exporter.
Args:
tracer_provider: Optional tracer provider to use. If not provided,
the global tracer provider will be used.
span_ttl_seconds: TTL for incomplete traces in seconds. If None,
uses LANGSMITH_OTEL_SPAN_TTL_SECONDS env var (default: 3600s)
"""
# Set defaults from environment variables if not provided
if span_ttl_seconds is None:
span_ttl_seconds = int(
ls_utils.get_env_var("OTEL_SPAN_TTL_SECONDS", default="3600")
)
otel_imports = _import_otel_exporter()
if otel_imports is None:
self._tracer = None
self._span_info = {}
self._otel_available = False
self._trace = None
self._span_ttl_seconds = span_ttl_seconds
self._last_cleanup = 0.0
else:
(
trace,
Context,
NonRecordingSpan,
Span,
SpanContext,
TraceFlags,
TraceState,
set_span_in_context,
) = otel_imports
self._tracer = trace.get_tracer(
"langsmith", tracer_provider=tracer_provider
)
self._span_info = {}
self._otel_available = True
self._trace = trace
self._span_ttl_seconds = span_ttl_seconds
self._last_cleanup = 0.0
def export_batch(
self,
operations: list[SerializedRunOperation],
otel_context_map: dict[uuid.UUID, Optional[Context]],
) -> None:
"""Export a batch of serialized run operations to OTEL.
Args:
operations: List of serialized run operations to export.
"""
# Proactive cleanup of expired and excess spans before new operations
self._cleanup_stale_spans()
for op in operations:
try:
run_info = self._deserialize_run_info(op)
if not run_info:
continue
if op.operation == "post":
span = self._create_span_for_run(
op, run_info, otel_context_map.get(op.id)
)
if span:
self._span_info[op.id] = {
"span": span,
"created_at": time.time(),
}
else:
self._update_span_for_run(op, run_info)
except Exception as e:
logger.exception(f"Error processing operation {op.id}: {e}")
def _deserialize_run_info(self, op: SerializedRunOperation) -> Optional[dict]:
"""Deserialize the run info from the operation.
Args:
op: The serialized run operation.
Returns:
The deserialized run info as a dictionary, or None if deserialization
failed.
"""
try:
return op.deserialize_run_info()
except Exception as e:
logger.exception(f"Failed to deserialize run info for {op.id}: {e}")
return None
def _create_span_for_run(
self,
op: SerializedRunOperation,
run_info: dict,
otel_context: Optional[Context] = None,
) -> Optional[Span]:
"""Create an OpenTelemetry span for a run operation.
Args:
op: The serialized run operation.
run_info: The deserialized run info.
parent_span: Optional parent span.
Returns:
The created span, or None if creation failed.
"""
try:
start_time = run_info.get("start_time")
start_time_utc_nano = self._as_utc_nano(start_time)
end_time = run_info.get("end_time")
end_time_utc_nano = self._as_utc_nano(end_time)
# Create deterministic trace and span IDs to match user OpenTelemetry spans
trace_id_int = get_otel_trace_id_from_uuid(op.trace_id)
span_id_int = get_otel_span_id_from_uuid(op.id)
# Get OTEL imports for this operation
otel_imports = _import_otel_exporter()
if otel_imports is None:
return None
(
trace,
Context,
NonRecordingSpan,
Span,
SpanContext,
TraceFlags,
TraceState,
set_span_in_context,
) = otel_imports
# Create SpanContext with deterministic IDs
span_context = SpanContext(
trace_id=trace_id_int,
span_id=span_id_int,
is_remote=False,
trace_flags=TraceFlags(TraceFlags.SAMPLED),
trace_state=TraceState(),
)
# Create NonRecordingSpan for context setting
non_recording_span = NonRecordingSpan(span_context)
deterministic_context = set_span_in_context(non_recording_span)
# Start the span with appropriate context
parent_run_id = run_info.get("parent_run_id")
if (
parent_run_id is not None
and uuid.UUID(parent_run_id) in self._span_info
):
# Use the parent span context
parent_span = self._span_info[uuid.UUID(parent_run_id)]["span"]
span = self._tracer.start_span(
run_info.get("name"),
context=set_span_in_context(parent_span),
start_time=start_time_utc_nano,
)
else:
# For root spans, check if there's an existing OpenTelemetry context
# If so, inherit from it; otherwise use our deterministic context
current_context = (
otel_context if otel_context else deterministic_context
)
span = self._tracer.start_span(
run_info.get("name"),
context=current_context,
start_time=start_time_utc_nano,
)
# Set all attributes
self._set_span_attributes(span, run_info, op)
# Set status based on error
if run_info.get("error"):
span.set_status(trace.StatusCode.ERROR)
span.record_exception(Exception(run_info.get("error")))
else:
span.set_status(trace.StatusCode.OK)
# End the span if end_time is present
end_time = run_info.get("end_time")
if end_time:
end_time_utc_nano = self._as_utc_nano(end_time)
if end_time_utc_nano:
span.end(end_time=end_time_utc_nano)
else:
span.end()
return span
except Exception as e:
logger.exception(f"Failed to create span for run {op.id}: {e}")
return None
def _update_span_for_run(self, op: SerializedRunOperation, run_info: dict) -> None:
"""Update an OpenTelemetry span for a run operation.
Args:
op: The serialized run operation.
run_info: The deserialized run info.
"""
try:
# Get the span for this run
if op.id not in self._span_info:
logger.debug(f"No span found for run {op.id} during update")
return
span = self._span_info[op.id]["span"]
# Update attributes
self._set_span_attributes(span, run_info, op)
# Update status based on error
if run_info.get("error"):
span.set_status(self._trace.StatusCode.ERROR)
span.record_exception(Exception(run_info.get("error")))
else:
span.set_status(self._trace.StatusCode.OK)
# End the span if end_time is present
end_time = run_info.get("end_time")
if end_time:
end_time_utc_nano = self._as_utc_nano(end_time)
if end_time_utc_nano:
span.end(end_time=end_time_utc_nano)
else:
span.end()
# Remove the span info from our dictionary
del self._span_info[op.id]
logger.debug(f"Completed span, remaining spans: {len(self._span_info)}")
else:
# Span exists but no end_time - this is normal for ongoing operations
logger.debug("Updated span (no end_time yet)")
except Exception as e:
logger.exception(f"Failed to update span for run {op.id}: {e}")
def _cleanup_stale_spans(self) -> None:
"""Clean up spans older than TTL threshold."""
if not self._span_info:
return
current_time = time.time()
# Only run cleanup every 10 seconds to reduce overhead
if current_time - self._last_cleanup < 10.0:
return
self._last_cleanup = current_time
cutoff_time = current_time - self._span_ttl_seconds
# Remove spans older than TTL in one pass
stale_span_ids = [
span_id
for span_id, info in self._span_info.items()
if info["created_at"] < cutoff_time
]
if stale_span_ids:
logger.info(
f" LangSmith OTEL Cleanup: Removing {len(stale_span_ids)} stale spans"
)
for span_id in stale_span_ids:
self._remove_span(span_id)
def _remove_span(self, span_id: uuid.UUID) -> None:
"""Remove a single span and clean up resources.
Note:
We call `span.end()` here because spans in `_span_info` are orphaned -
they never received their patch operation and will never naturally complete.
Ending them gracefully is better than leaving them open indefinitely.
"""
if span_id not in self._span_info:
return
try:
# End the orphaned span gracefully
span = self._span_info[span_id]["span"]
# Check if span is still active before ending it
if (
hasattr(span, "end")
and hasattr(span, "is_recording")
and span.is_recording()
):
span.end()
logger.debug(f"Ended orphaned span {span_id}")
elif hasattr(span, "end"):
# Span already ended, just log it
logger.debug(f"Span {span_id} already ended, skipping end() call")
# Remove from tracking regardless
del self._span_info[span_id]
except Exception as e:
logger.debug(f"Error removing span {span_id}: {e}")
# Still try to remove from tracking even if ending failed
try:
del self._span_info[span_id]
except KeyError:
pass
def _extract_model_name(self, run_info: dict) -> Optional[str]:
"""Extract model name from run info.
Args:
run_info: The run info.
Returns:
The model name, or None if not found.
"""
# Try to get model name from metadata
if run_info.get("extra") and run_info["extra"].get("metadata"):
metadata = run_info["extra"]["metadata"]
# First check for ls_model_name in metadata
if metadata.get("ls_model_name"):
return metadata["ls_model_name"]
# Then check invocation_params for model info
if "invocation_params" in metadata:
invocation_params = metadata["invocation_params"]
# Check model first, then model_name
if invocation_params.get("model"):
return invocation_params["model"]
elif invocation_params.get("model_name"):
return invocation_params["model_name"]
return None
def _set_span_attributes(
self,
span: Span,
run_info: dict,
op: SerializedRunOperation,
) -> None:
"""Set attributes on the span.
Args:
span: The span to set attributes on.
run_info: The deserialized run info.
op: The serialized run operation.
"""
# Set LangSmith-specific attributes
if run_info.get("run_type"):
span.set_attribute(LANGSMITH_RUN_TYPE, str(run_info.get("run_type")))
if run_info.get("name"):
span.set_attribute(LANGSMITH_NAME, str(run_info.get("name")))
if run_info.get("session_id"):
span.set_attribute(LANGSMITH_SESSION_ID, str(run_info.get("session_id")))
if run_info.get("session_name"):
span.set_attribute(
LANGSMITH_SESSION_NAME, str(run_info.get("session_name"))
)
# Set GenAI attributes according to OTEL semantic conventions
# Set gen_ai.operation.name
if op.operation == "post":
operation_name = _get_operation_name(run_info.get("run_type", "chain"))
span.set_attribute(GEN_AI_OPERATION_NAME, operation_name)
# Set gen_ai.system
self._set_gen_ai_system(span, run_info)
# Set model name if available
model_name = self._extract_model_name(run_info)
if model_name:
span.set_attribute(GEN_AI_REQUEST_MODEL, model_name)
# Set token usage information
if run_info.get("prompt_tokens") is not None:
prompt_tokens = run_info["prompt_tokens"]
span.set_attribute(GEN_AI_USAGE_INPUT_TOKENS, int(prompt_tokens))
if run_info.get("completion_tokens") is not None:
completion_tokens = run_info["completion_tokens"]
span.set_attribute(GEN_AI_USAGE_OUTPUT_TOKENS, int(completion_tokens))
if run_info.get("total_tokens") is not None:
total_tokens = run_info["total_tokens"]
span.set_attribute(GEN_AI_USAGE_TOTAL_TOKENS, int(total_tokens))
# Set other parameters from invocation_params
self._set_invocation_parameters(span, run_info)
# Set metadata and tags if available
extra = run_info.get("extra", {})
metadata = extra.get("metadata", {})
for key, value in metadata.items():
if value is not None:
span.set_attribute(f"{LANGSMITH_METADATA}.{key}", value)
tags = run_info.get("tags")
if tags:
if isinstance(tags, list):
span.set_attribute(LANGSMITH_TAGS, ", ".join(tags))
else:
span.set_attribute(LANGSMITH_TAGS, tags)
# Support additional serialized attributes, if present
if run_info.get("serialized") and isinstance(run_info["serialized"], dict):
serialized = run_info["serialized"]
if "name" in serialized and serialized["name"] is not None:
span.set_attribute(GEN_AI_SERIALIZED_NAME, serialized["name"])
if "signature" in serialized and serialized["signature"] is not None:
span.set_attribute(GEN_AI_SERIALIZED_SIGNATURE, serialized["signature"])
if "doc" in serialized and serialized["doc"] is not None:
span.set_attribute(GEN_AI_SERIALIZED_DOC, serialized["doc"])
# Set inputs/outputs if available
self._set_io_attributes(span, op)
def _set_gen_ai_system(self, span: Span, run_info: dict) -> None:
"""Set the gen_ai.system attribute on the span based on the model provider.
Args:
span: The span to set attributes on.
run_info: The deserialized run info.
"""
# Default to "langchain" if we can't determine the system
system = "langchain"
# Extract model name to determine the system
model_name = self._extract_model_name(run_info)
if model_name:
model_lower = model_name.lower()
if "anthropic" in model_lower or model_lower.startswith("claude"):
system = "anthropic"
elif "bedrock" in model_lower:
system = "aws.bedrock"
elif "azure" in model_lower and "openai" in model_lower:
system = "az.ai.openai"
elif "azure" in model_lower and "inference" in model_lower:
system = "az.ai.inference"
elif "cohere" in model_lower:
system = "cohere"
elif "deepseek" in model_lower:
system = "deepseek"
elif "gemini" in model_lower:
system = "gemini"
elif "groq" in model_lower:
system = "groq"
elif "watson" in model_lower or "ibm" in model_lower:
system = "ibm.watsonx.ai"
elif "mistral" in model_lower:
system = "mistral_ai"
elif "gpt" in model_lower or "openai" in model_lower:
system = "openai"
elif "perplexity" in model_lower or "sonar" in model_lower:
system = "perplexity"
elif "vertex" in model_lower:
system = "vertex_ai"
elif "xai" in model_lower or "grok" in model_lower:
system = "xai"
elif "qwen" in model_lower:
system = "qwen"
span.set_attribute(GEN_AI_SYSTEM, system)
setattr(span, "_gen_ai_system", system)
def _set_invocation_parameters(self, span: Span, run_info: dict) -> None:
"""Set invocation parameters on the span.
Args:
span: The span to set attributes on.
run_info: The deserialized run info.
"""
if not (run_info.get("extra") and run_info["extra"].get("metadata")):
return
metadata = run_info["extra"]["metadata"]
if "invocation_params" not in metadata:
return
invocation_params = metadata["invocation_params"]
# Set relevant invocation parameters
if "max_tokens" in invocation_params:
span.set_attribute(
GEN_AI_REQUEST_MAX_TOKENS, invocation_params["max_tokens"]
)
if "temperature" in invocation_params:
span.set_attribute(
GEN_AI_REQUEST_TEMPERATURE, invocation_params["temperature"]
)
if "top_p" in invocation_params:
span.set_attribute(GEN_AI_REQUEST_TOP_P, invocation_params["top_p"])
if "frequency_penalty" in invocation_params:
span.set_attribute(
GEN_AI_REQUEST_FREQUENCY_PENALTY, invocation_params["frequency_penalty"]
)
if "presence_penalty" in invocation_params:
span.set_attribute(
GEN_AI_REQUEST_PRESENCE_PENALTY, invocation_params["presence_penalty"]
)
def _set_io_attributes(self, span: Span, op: SerializedRunOperation) -> None:
"""Set input/output attributes on the span.
Args:
span: The span to set attributes on.
op: The serialized run operation.
"""
if op.inputs:
try:
inputs = _orjson.loads(op.inputs)
if isinstance(inputs, dict):
if (
"model" in inputs
and isinstance(inputs.get("messages"), list)
and inputs["model"] is not None
):
span.set_attribute(GEN_AI_REQUEST_MODEL, inputs["model"])
# Set additional request attributes if available.
if "stream" in inputs and inputs["stream"] is not None:
span.set_attribute(
LANGSMITH_REQUEST_STREAMING, inputs["stream"]
)
if (
"extra_headers" in inputs
and inputs["extra_headers"] is not None
):
span.set_attribute(
LANGSMITH_REQUEST_HEADERS, inputs["extra_headers"]
)
if "extra_query" in inputs and inputs["extra_query"] is not None:
span.set_attribute(
GEN_AI_REQUEST_EXTRA_QUERY, inputs["extra_query"]
)
if "extra_body" in inputs and inputs["extra_body"] is not None:
span.set_attribute(
GEN_AI_REQUEST_EXTRA_BODY, inputs["extra_body"]
)
span.set_attribute(GENAI_PROMPT, op.inputs)
except Exception:
logger.debug(
"Failed to process inputs for run %s", op.id, exc_info=True
)
if op.outputs:
try:
outputs = _orjson.loads(op.outputs)
# Extract token usage from outputs (for LLM runs)
token_usage = self.get_unified_run_tokens(outputs)
if token_usage:
span.set_attribute(GEN_AI_USAGE_INPUT_TOKENS, token_usage[0])
span.set_attribute(GEN_AI_USAGE_OUTPUT_TOKENS, token_usage[1])
span.set_attribute(
GEN_AI_USAGE_TOTAL_TOKENS, token_usage[0] + token_usage[1]
)
if "model" in outputs:
span.set_attribute(GEN_AI_RESPONSE_MODEL, str(outputs["model"]))
# Extract additional response attributes.
if isinstance(outputs, dict):
if "id" in outputs and outputs["id"] is not None:
span.set_attribute(GEN_AI_RESPONSE_ID, outputs["id"])
if "choices" in outputs and isinstance(outputs["choices"], list):
finish_reasons = []
for choice in outputs["choices"]:
if (
"finish_reason" in choice
and choice["finish_reason"] is not None
):
finish_reasons.append(str(choice["finish_reason"]))
if finish_reasons:
span.set_attribute(
GEN_AI_RESPONSE_FINISH_REASONS,
", ".join(finish_reasons),
)
if (
"service_tier" in outputs
and outputs["service_tier"] is not None
):
span.set_attribute(
GEN_AI_RESPONSE_SERVICE_TIER, outputs["service_tier"]
)
if (
"system_fingerprint" in outputs
and outputs["system_fingerprint"] is not None
):
span.set_attribute(
GEN_AI_RESPONSE_SYSTEM_FINGERPRINT,
outputs["system_fingerprint"],
)
if "usage_metadata" in outputs and isinstance(
outputs["usage_metadata"], dict
):
usage_metadata = outputs["usage_metadata"]
if (
"input_token_details" in usage_metadata
and usage_metadata["input_token_details"] is not None
):
input_token_details = str(
usage_metadata["input_token_details"]
)
span.set_attribute(
GEN_AI_USAGE_INPUT_TOKEN_DETAILS, input_token_details
)
if (
"output_token_details" in usage_metadata
and usage_metadata["output_token_details"] is not None
):
output_token_details = str(
usage_metadata["output_token_details"]
)
span.set_attribute(
GEN_AI_USAGE_OUTPUT_TOKEN_DETAILS, output_token_details
)
span.set_attribute(GENAI_COMPLETION, op.outputs)
except Exception:
logger.debug(
"Failed to process outputs for run %s", op.id, exc_info=True
)
def _as_utc_nano(self, timestamp: Optional[str]) -> Optional[int]:
if not timestamp:
return None
try:
dt = datetime.datetime.fromisoformat(timestamp)
return int(dt.astimezone(datetime.timezone.utc).timestamp() * 1_000_000_000)
except ValueError:
logger.exception(f"Failed to parse timestamp {timestamp}")
return None
def get_unified_run_tokens(
self, outputs: Optional[dict]
) -> Optional[tuple[int, int]]:
if not outputs:
return None
# search in non-generations lists
if output := self._extract_unified_run_tokens(outputs.get("usage_metadata")):
return output
# find if direct kwarg in outputs
keys = outputs.keys()
for key in keys:
haystack = outputs[key]
if not haystack or not isinstance(haystack, dict):
continue
if output := self._extract_unified_run_tokens(
haystack.get("usage_metadata")
):
return output
if (
haystack.get("lc") == 1
and "kwargs" in haystack
and isinstance(haystack["kwargs"], dict)
and (
output := self._extract_unified_run_tokens(
haystack["kwargs"].get("usage_metadata")
)
)
):
return output
# find in generations
generations = outputs.get("generations") or []
if not isinstance(generations, list):
return None
if generations and not isinstance(generations[0], list):
generations = [generations]
for generation in [x for xs in generations for x in xs]:
if (
isinstance(generation, dict)
and "message" in generation
and isinstance(generation["message"], dict)
and "kwargs" in generation["message"]
and isinstance(generation["message"]["kwargs"], dict)
and (
output := self._extract_unified_run_tokens(
generation["message"]["kwargs"].get("usage_metadata")
)
)
):
return output
return None
def _extract_unified_run_tokens(
self, outputs: Optional[Any]
) -> Optional[tuple[int, int]]:
if not outputs or not isinstance(outputs, dict):
return None
if "input_tokens" not in outputs or "output_tokens" not in outputs:
return None
if not isinstance(outputs["input_tokens"], int) or not isinstance(
outputs["output_tokens"], int
):
return None
return outputs["input_tokens"], outputs["output_tokens"]