feat: Added Traceloop SDK for collecting traces and metrics from Langflow (#9317)

* Added Traceloop SDK for colecting traces and metrics from Langflow

* [autofix.ci] apply automated fixes

* added test case for traceloop

* Revert "[autofix.ci] apply automated fixes"

This reverts commit 3a68113f0de65b2397ac88d1fc2cae8f786adbd7.

* Updated logic for verifying returned callbacks list length

* [autofix.ci] apply automated fixes

* api key strip validation and updated logger warning

* Add graceful fallback for Traceloop LangChain callback handler

* Removed TraceloopLangChainCallbackHandler

* default URL moved to configuration constant

* add Timeout Protection to add trace method

* add resource cleanup method for traceloop

* fix(tracing): unify span lifecycle and fix async mismatch in TraceloopTracer

- Remove incorrect asyncio.wait_for usage on synchronous method
- Store active spans in _span_map to properly end them in end_trace
- Prevent duplicate unrelated spans for same component
- Use trace.get_tracer_provider().force_flush() for correct flushing
- Add explicit close() method for manual flush at shutdown
- Enforce HTTPS in TRACELOOP_BASE_URL validation
- Improve reliability of cleanup in __del__

* stable uv.lock from upstream/main

* Fix: ensure type-safe metadata handling

* Block PYMYSQL intrumentor and relaxed https constraint

* fix(tracing): handle invalid input/output type conversion for traceloop attributes

* fix: Reduced cognitive complexity of get_trace_as_metadata method

* fix: organize child spans under single root span and added type conversion methods

* fix: Linting and formatting

* uv.lock reset to upstream main

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Sandesh R <sandesh@ibm.com>
This commit is contained in:
Sandesh R 2025-08-22 22:07:24 +05:30 • committed by GitHub
commit 3ecf9640b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 880 additions and 4 deletions

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import ast
import asyncio
import inspect
import json
from collections.abc import AsyncIterator, Iterator
from copy import deepcopy
from textwrap import dedent
@ -1019,10 +1020,23 @@ class Component(CustomComponent):
return {**predefined_inputs, **runtime_inputs}
def get_trace_as_metadata(self):
def safe_list_values(items):
return [v if isinstance(v, str | int | float | bool) or v is None else str(v) for v in items]
def safe_value(val):
if isinstance(val, str | int | float | bool) or val is None:
return val
if isinstance(val, list | tuple):
return safe_list_values(val)
try:
return json.dumps(val)
except (TypeError, ValueError):
return str(val)
return {
input_.name: input_.value
input_.name: safe_value(getattr(self, input_.name, input_.value))
for input_ in self.inputs
if hasattr(input_, "trace_as_metadata") and input_.trace_as_metadata
if getattr(input_, "trace_as_metadata", False)
}
async def _build_with_tracing(self):

View file

@ -53,6 +53,12 @@ def _get_opik_tracer():
return OpikTracer
def _get_traceloop_tracer():
from langflow.services.tracing.traceloop import TraceloopTracer
return TraceloopTracer
trace_context_var: ContextVar[TraceContext | None] = ContextVar("trace_context", default=None)
component_context_var: ContextVar[ComponentTraceContext | None] = ContextVar("component_trace_context", default=None)
@ -201,6 +207,19 @@ class TracingService(Service):
session_id=trace_context.session_id,
)
def _initialize_traceloop_tracer(self, trace_context: TraceContext) -> None:
if self.deactivated:
return
traceloop_tracer = _get_traceloop_tracer()
trace_context.tracers["traceloop"] = traceloop_tracer(
trace_name=trace_context.run_name,
trace_type="chain",
project_name=trace_context.project_name,
trace_id=trace_context.run_id,
user_id=trace_context.user_id,
session_id=trace_context.session_id,
)
async def start_tracers(
self,
run_id: UUID,
@ -227,6 +246,7 @@ class TracingService(Service):
self._initialize_langfuse_tracer(trace_context)
self._initialize_arize_phoenix_tracer(trace_context)
self._initialize_opik_tracer(trace_context)
self._initialize_traceloop_tracer(trace_context)
except Exception as e: # noqa: BLE001
logger.debug(f"Error initializing tracers: {e}")

View file

@ -0,0 +1,245 @@
from __future__ import annotations
import json
import math
import os
import types
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from loguru import logger
from opentelemetry import trace
from opentelemetry.trace import Span, use_span
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from traceloop.sdk import Traceloop
from traceloop.sdk.instruments import Instruments
from typing_extensions import override
from langflow.services.tracing.base import BaseTracer
if TYPE_CHECKING:
from collections.abc import Sequence
from uuid import UUID
from langchain.callbacks.base import BaseCallbackHandler
from opentelemetry.propagators.textmap import CarrierT
from opentelemetry.trace import Span
from langflow.graph.vertex.base import Vertex
from langflow.services.tracing.schema import Log
class TraceloopTracer(BaseTracer):
"""Traceloop tracer for Langflow."""
def __init__(
self,
trace_name: str,
trace_type: str,
project_name: str,
trace_id: UUID,
user_id: str | None = None,
session_id: str | None = None,
):
self.trace_id = trace_id
self.trace_name = trace_name
self.trace_type = trace_type
self.project_name = project_name
self.user_id = user_id
self.session_id = session_id
self.child_spans: dict[str, Span] = {}
if not self._validate_configuration():
self._ready = False
return
api_key = os.getenv("TRACELOOP_API_KEY", "").strip()
try:
Traceloop.init(
block_instruments={Instruments.PYMYSQL},
app_name=project_name,
disable_batch=True,
api_key=api_key,
api_endpoint=os.getenv("TRACELOOP_BASE_URL", "https://api.traceloop.com"),
)
self._ready = True
self._tracer = trace.get_tracer("langflow")
self.propagator = TraceContextTextMapPropagator()
self.carrier: CarrierT = {}
self.root_span = self._tracer.start_span(
name=trace_name,
start_time=self._get_current_timestamp(),
)
with use_span(self.root_span, end_on_exit=False):
self.propagator.inject(carrier=self.carrier)
except Exception: # noqa: BLE001
logger.opt(exception=True).debug("Error setting up Traceloop tracer")
self._ready = False
@property
def ready(self) -> bool:
return self._ready
def _validate_configuration(self) -> bool:
api_key = os.getenv("TRACELOOP_API_KEY", "").strip()
if not api_key:
logger.warning("TRACELOOP_API_KEY not set or empty.")
return False
base_url = os.getenv("TRACELOOP_BASE_URL", "https://api.traceloop.com")
parsed = urlparse(base_url)
if not parsed.netloc:
logger.error(f"Invalid TRACELOOP_BASE_URL: {base_url}")
return False
return True
def _convert_to_traceloop_type(self, value):
"""Recursively converts a value to a Traceloop compatible type."""
from langchain.schema import BaseMessage, Document, HumanMessage, SystemMessage
from langflow.schema.message import Message
try:
if isinstance(value, dict):
value = {key: self._convert_to_traceloop_type(val) for key, val in value.items()}
elif isinstance(value, list):
value = [self._convert_to_traceloop_type(v) for v in value]
elif isinstance(value, Message):
value = value.text
elif isinstance(value, (BaseMessage | HumanMessage | SystemMessage)):
value = str(value.content) if value.content is not None else ""
elif isinstance(value, Document):
value = value.page_content
elif isinstance(value, (types.GeneratorType | types.NoneType)):
value = str(value)
elif isinstance(value, float) and not math.isfinite(value):
value = "NaN"
except (TypeError, ValueError) as e:
logger.warning(f"Failed to convert value {value!r} to traceloop type: {e}")
return str(value)
else:
return value
def _convert_to_traceloop_dict(self, io_dict: Any) -> dict[str, Any]:
"""Ensure values are OTel-compatible. Dicts stay dicts, lists get JSON-serialized."""
if isinstance(io_dict, dict):
return {str(k): self._convert_to_traceloop_type(v) for k, v in io_dict.items()}
if isinstance(io_dict, list):
return {"list": json.dumps([self._convert_to_traceloop_type(v) for v in io_dict], default=str)}
return {"value": self._convert_to_traceloop_type(io_dict)}
@override
def add_trace(
self,
trace_id: str,
trace_name: str,
trace_type: str,
inputs: dict[str, Any],
metadata: dict[str, Any] | None = None,
vertex: Vertex | None = None,
) -> None:
if not self.ready:
return
span_context = self.propagator.extract(carrier=self.carrier)
child_span = self._tracer.start_span(
name=trace_name,
context=span_context,
start_time=self._get_current_timestamp(),
)
attributes = {
"trace_id": trace_id,
"trace_name": trace_name,
"trace_type": trace_type,
"inputs": json.dumps(self._convert_to_traceloop_dict(inputs), default=str),
**self._convert_to_traceloop_dict(metadata or {}),
}
if vertex and vertex.id is not None:
attributes["vertex_id"] = vertex.id
child_span.set_attributes(attributes)
self.child_spans[trace_id] = child_span
@override
def end_trace(
self,
trace_id: str,
trace_name: str,
outputs: dict[str, Any] | None = None,
error: Exception | None = None,
logs: Sequence[Log | dict] = (),
) -> None:
if not self._ready or trace_id not in self.child_spans:
return
child_span = self.child_spans.pop(trace_id)
if outputs:
child_span.set_attribute("outputs", json.dumps(self._convert_to_traceloop_dict(outputs), default=str))
if logs:
child_span.set_attribute("logs", json.dumps(self._convert_to_traceloop_dict(list(logs)), default=str))
if error:
child_span.record_exception(error)
child_span.end()
@override
def end(
self,
inputs: dict[str, Any],
outputs: dict[str, Any],
error: Exception | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
if not self.ready:
return
safe_outputs = self._convert_to_traceloop_dict(outputs)
safe_metadata = self._convert_to_traceloop_dict(metadata or {})
self.root_span.set_attributes(
{
"workflow_name": self.trace_name,
"workflow_id": str(self.trace_id),
"outputs": json.dumps(safe_outputs, default=str),
**safe_metadata,
}
)
if error:
self.root_span.record_exception(error)
self.root_span.end()
@staticmethod
def _get_current_timestamp() -> int:
return int(datetime.now(timezone.utc).timestamp() * 1_000_000_000)
@override
def get_langchain_callback(self) -> BaseCallbackHandler | None:
return None
def close(self):
try:
provider = trace.get_tracer_provider()
if hasattr(provider, "force_flush"):
provider.force_flush(timeout_millis=3000)
except (ValueError, RuntimeError, OSError) as e:
logger.warning(f"Error flushing spans: {e}")
def __del__(self):
self.close()

View file

@ -139,6 +139,10 @@ def mock_tracers():
"langflow.services.tracing.service._get_opik_tracer",
return_value=MockTracer,
),
patch(
"langflow.services.tracing.service._get_traceloop_tracer",
return_value=MockTracer,
),
):
yield
@ -169,6 +173,7 @@ async def test_start_end_tracers(tracing_service):
assert "langwatch" in trace_context.tracers
assert "langfuse" in trace_context.tracers
assert "arize_phoenix" in trace_context.tracers
assert "traceloop" in trace_context.tracers
await tracing_service.end_tracers(outputs)
@ -298,7 +303,8 @@ async def test_get_langchain_callbacks(tracing_service):
assert tracer.get_langchain_callback_called
# Verify returned callbacks list length
assert len(callbacks) == 5 # Five tracers
expected = len(trace_context_var.get().tracers)
assert len(callbacks) == expected
# Cleanup
await tracing_service.end_tracers({})