85742ab165
Deploy Documentation / deploy (push) Has been cancelled
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, latest, Python 3.13) (push) Has been cancelled
Dashboard / Chromatic (push) Has been cancelled
CPU Test / Lint - fast (push) Has been cancelled
CPU Test / Lint - next (push) Has been cancelled
CPU Test / Lint - slow (push) Has been cancelled
CPU Test / Lint - JavaScript (push) Has been cancelled
CPU Test / Build documentation (push) Has been cancelled
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Others, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Store, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Weave, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Others, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Store, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Utilities, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (JavaScript) (push) Has been cancelled
678 lines
26 KiB
Python
678 lines
26 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import concurrent.futures as futures
|
|
import logging
|
|
import os
|
|
import re
|
|
import weakref
|
|
from contextlib import asynccontextmanager, contextmanager
|
|
from datetime import datetime
|
|
from typing import (
|
|
Any,
|
|
AsyncIterator,
|
|
Callable,
|
|
Dict,
|
|
Iterator,
|
|
List,
|
|
Optional,
|
|
cast,
|
|
)
|
|
|
|
import weave
|
|
from opentelemetry.semconv.attributes import exception_attributes
|
|
from weave.trace.call import Call
|
|
from weave.trace.settings import UserSettings
|
|
from weave.trace.weave_client import WeaveClient
|
|
from weave.trace_server import trace_server_interface as tsi
|
|
from weave.wandb_interface.context import set_wandb_api_context
|
|
|
|
from agentlightning.instrumentation.weave import InMemoryWeaveTraceServer, instrument_weave, uninstrument_weave
|
|
from agentlightning.semconv import LightningResourceAttributes, LightningSpanAttributes
|
|
from agentlightning.store.base import LightningStore
|
|
from agentlightning.types import (
|
|
Attributes,
|
|
OtelResource,
|
|
Span,
|
|
SpanContext,
|
|
SpanCoreFields,
|
|
SpanRecordingContext,
|
|
StatusCode,
|
|
TraceStatus,
|
|
)
|
|
from agentlightning.utils.id import generate_id
|
|
from agentlightning.utils.otel import (
|
|
filter_and_unflatten_attributes,
|
|
flatten_attributes,
|
|
format_exception_attributes,
|
|
sanitize_attributes,
|
|
)
|
|
|
|
from .base import Tracer, with_active_tracer_context
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def op_name_to_func_name(op_name: str) -> str:
|
|
"""Convert a Weave operation name to a function name.
|
|
|
|
Weave operation names look like this: `weave:///xxx/agentlightning.tracer.weave/op/openai.chat.completions.create:019b10be-...-44d74272569c`
|
|
"""
|
|
match = re.search(r"/([^/:]+):", op_name)
|
|
if match:
|
|
return match.group(1)
|
|
else:
|
|
return op_name
|
|
|
|
|
|
def random_project_name() -> str:
|
|
return "agl/weave-" + generate_id(12)
|
|
|
|
|
|
def get_timestamp_or_throw(date: Optional[datetime], field_name: str) -> float:
|
|
if date is None:
|
|
raise ValueError(f"{field_name} is required but not set")
|
|
return date.timestamp()
|
|
|
|
|
|
class WeaveSpanRecordingContext(SpanRecordingContext):
|
|
"""Universal interface for recording operations on a Weave call."""
|
|
|
|
def __init__(self, call: Call) -> None:
|
|
self._call = call
|
|
|
|
def record_exception(self, exception: BaseException) -> None:
|
|
self._call.exception = str(exception)
|
|
self.record_status("ERROR", str(exception))
|
|
self.record_attributes(format_exception_attributes(exception))
|
|
|
|
def _get_input_from_attributes(self, attributes: Attributes) -> Dict[str, Any]:
|
|
if LightningSpanAttributes.OPERATION_INPUT.value in attributes:
|
|
# This can be a very rare case. If it happens, we can just let it throw.
|
|
return cast(Dict[str, Any], attributes[LightningSpanAttributes.OPERATION_INPUT.value])
|
|
else:
|
|
filtered_attributes = filter_and_unflatten_attributes(
|
|
attributes, LightningSpanAttributes.OPERATION_INPUT.value
|
|
)
|
|
if isinstance(filtered_attributes, list):
|
|
return {str(i): v for i, v in enumerate(filtered_attributes)}
|
|
else:
|
|
return filtered_attributes
|
|
|
|
def _get_output_from_attributes(self, attributes: Attributes) -> Any:
|
|
if LightningSpanAttributes.OPERATION_OUTPUT.value in attributes:
|
|
return attributes[LightningSpanAttributes.OPERATION_OUTPUT.value]
|
|
else:
|
|
return filter_and_unflatten_attributes(attributes, LightningSpanAttributes.OPERATION_OUTPUT.value)
|
|
|
|
def record_attributes(self, attributes: Attributes) -> None:
|
|
input_attributes = self._get_input_from_attributes(attributes)
|
|
if input_attributes:
|
|
self._call.inputs.update(input_attributes)
|
|
|
|
output_attributes = self._get_output_from_attributes(attributes)
|
|
if output_attributes:
|
|
if self._call.output is not None:
|
|
logger.warning(f"Output is already set. It will be overridden: {self._call.output}")
|
|
self._call.output = output_attributes
|
|
|
|
if LightningSpanAttributes.OPERATION_NAME.value in attributes:
|
|
logger.error(
|
|
f"Cannot record operation name as an attribute. It will be skipped: {attributes[LightningSpanAttributes.OPERATION_NAME.value]}"
|
|
)
|
|
|
|
# The rest of the attributes are recorded as summary.
|
|
for key, value in attributes.items():
|
|
if (
|
|
not key == LightningSpanAttributes.OPERATION_INPUT.value
|
|
and not key.startswith(LightningSpanAttributes.OPERATION_INPUT.value + ".")
|
|
and not key == LightningSpanAttributes.OPERATION_OUTPUT.value
|
|
and not key.startswith(LightningSpanAttributes.OPERATION_OUTPUT.value + ".")
|
|
and not key == LightningSpanAttributes.OPERATION_NAME.value
|
|
):
|
|
if self._call.summary is None:
|
|
self._call.summary = {}
|
|
self._call.summary[key] = value
|
|
|
|
def record_status(self, status_code: StatusCode, description: Optional[str] = None) -> None:
|
|
if status_code == "ERROR":
|
|
if not description:
|
|
raise ValueError("Description is required when status code is ERROR")
|
|
self._call.exception = description
|
|
elif status_code == "OK":
|
|
self._call.exception = None
|
|
# Do nothing for other status codes.
|
|
|
|
def finalize(self) -> None:
|
|
# Do nothing
|
|
pass
|
|
|
|
def get_recorded_span(self) -> SpanCoreFields:
|
|
return SpanCoreFields(
|
|
name=self._call.op_name,
|
|
attributes=flatten_attributes(self._call.attributes or {}),
|
|
start_time=self._call.started_at.timestamp() if self._call.started_at else None,
|
|
end_time=self._call.ended_at.timestamp() if self._call.ended_at else None,
|
|
status=TraceStatus(
|
|
status_code="OK" if self._call.exception is None else "ERROR", description=self._call.exception
|
|
),
|
|
)
|
|
|
|
|
|
class WeaveTracerManagedTraceServer(InMemoryWeaveTraceServer):
|
|
"""A managed trace server for WeaveTracer."""
|
|
|
|
def __init__(
|
|
self,
|
|
partial_call_callback: Callable[[Dict[str, Any]], None],
|
|
complete_call_callback: Callable[[tsi.CallSchema], None],
|
|
):
|
|
super().__init__()
|
|
self.partial_call_callback = partial_call_callback
|
|
self.complete_call_callback = complete_call_callback
|
|
self._calls_already_invoked: set[str] = set()
|
|
|
|
def trigger_callbacks(self, call_id: str) -> None:
|
|
with self._call_threading_lock:
|
|
if call_id in self.calls:
|
|
if call_id not in self._calls_already_invoked:
|
|
self._calls_already_invoked.add(call_id)
|
|
self.complete_call_callback(self.calls[call_id])
|
|
else:
|
|
logger.info(f"Call {call_id} has callback already invoked. Skipping.")
|
|
elif call_id in self.partial_calls:
|
|
self.partial_call_callback(self.partial_calls[call_id])
|
|
else:
|
|
logger.error(f"Call {call_id} not found in partial_calls or calls")
|
|
|
|
def call_start(self, req: tsi.CallStartReq) -> tsi.CallStartRes:
|
|
try:
|
|
ret = super().call_start(req)
|
|
self.trigger_callbacks(ret.id)
|
|
return ret
|
|
except Exception:
|
|
logger.exception(f"Error calling call_start: {req}", exc_info=True)
|
|
raise
|
|
|
|
def call_end(self, req: tsi.CallEndReq) -> tsi.CallEndRes:
|
|
try:
|
|
ret = super().call_end(req)
|
|
self.trigger_callbacks(req.end.id)
|
|
return ret
|
|
except Exception:
|
|
logger.exception(f"Error calling call_end: {req}", exc_info=True)
|
|
raise
|
|
|
|
def clear(self) -> None:
|
|
self._calls_already_invoked.clear()
|
|
|
|
|
|
class WeaveTracer(Tracer):
|
|
"""Tracer implementation using Weave for telemetry and trace logging.
|
|
|
|
This replaces AgentOpsTracer with a Weave-based manual trace context. It tracks:
|
|
|
|
- Function/method calls
|
|
- Input/Output data
|
|
- Exceptions
|
|
|
|
and logs them to Weave Cloud (W&B backend) or optionally bypasses the network for testing.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
project_name: str | None = None,
|
|
weave_user_settings: UserSettings | None = None,
|
|
instrument_managed: bool = True,
|
|
):
|
|
"""Initialize a WeaveTracer instance.
|
|
|
|
Args:
|
|
project_name: Optional project name for Weave; defaults to the current module name.
|
|
weave_user_settings: Optional UserSettings for Weave.
|
|
instrument_managed: Whether to patch the Weave/W&B integration to bypass actual network calls for testing.
|
|
"""
|
|
super().__init__()
|
|
self.project_name = project_name
|
|
self.instrument_managed = instrument_managed
|
|
self.weave_user_settings = weave_user_settings or UserSettings(use_server_cache=False)
|
|
|
|
self._store: Optional[LightningStore] = None
|
|
self._server = WeaveTracerManagedTraceServer(
|
|
partial_call_callback=self.partial_call_callback, complete_call_callback=self.complete_call_callback
|
|
)
|
|
|
|
self._default_sequence_counter: int = 0
|
|
self._calls: Dict[str, tsi.CallSchema] = {} # call_id -> call
|
|
self._spans: List[Span] = [] # spans in the current trace
|
|
self._rollout_id: Optional[str] = None
|
|
self._attempt_id: Optional[str] = None
|
|
self._partial_call_futures: Dict[str, asyncio.Future[int] | futures.Future[int]] = {}
|
|
self._complete_call_futures: List[asyncio.Future[None] | futures.Future[None]] = []
|
|
self._loop: weakref.ReferenceType[asyncio.AbstractEventLoop] | None = None
|
|
|
|
def instrument(self, worker_id: int):
|
|
instrument_weave(self._server)
|
|
|
|
def uninstrument(self, worker_id: int):
|
|
uninstrument_weave()
|
|
|
|
def init_worker(self, worker_id: int, store: Optional[LightningStore] = None):
|
|
"""
|
|
Initialize the tracer for a worker thread/process.
|
|
|
|
Args:
|
|
worker_id: Identifier of the worker.
|
|
store: Optional LightningStore for storing spans.
|
|
"""
|
|
super().init_worker(worker_id, store)
|
|
logger.info(f"[Worker {worker_id}] Setting up Weave tracer...")
|
|
self._store = store
|
|
|
|
# Optionally patch network calls to bypass real Weave/W&B endpoints
|
|
if self.instrument_managed:
|
|
self.instrument(worker_id)
|
|
|
|
# If WANDB_API_KEY is not set, we need to initialize Weave with a hack
|
|
if not os.getenv("WANDB_API_KEY"):
|
|
logger.info("WANDB_API_KEY is not set. Initializing Weave a mock context.")
|
|
set_wandb_api_context("agl", api_key=None, headers=None, cookies=None)
|
|
else:
|
|
logger.debug("WANDB_API_KEY is set. Weave will be initialized automatically.")
|
|
|
|
weave_client = weave.get_client()
|
|
if self.project_name is None:
|
|
self.project_name = random_project_name()
|
|
|
|
if weave_client is not None:
|
|
logger.warning("Weave client was already initialized. Reentrant calls are at your own risk.")
|
|
if weave_client.project == self.project_name:
|
|
logger.error(
|
|
f"Weave client was already initialized for the same project '{self.project_name}'. It's very likely that weave won't work correctly."
|
|
)
|
|
|
|
# Init no matter what
|
|
try:
|
|
weave.init(project_name=self.project_name, settings=self.weave_user_settings)
|
|
logger.info(f"[Worker {worker_id}] Weave client initialized.")
|
|
except Exception as exc:
|
|
raise RuntimeError(f"Failed to initialize Weave for project '{self.project_name}'") from exc
|
|
|
|
def teardown_worker(self, worker_id: int):
|
|
"""
|
|
Clean up tracer resources for the worker.
|
|
|
|
Args:
|
|
worker_id: Identifier of the worker.
|
|
"""
|
|
super().teardown_worker(worker_id)
|
|
|
|
if self.instrument_managed:
|
|
self.uninstrument(worker_id)
|
|
logger.info(f"[Worker {worker_id}] Instrumentation removed.")
|
|
|
|
@with_active_tracer_context
|
|
@asynccontextmanager
|
|
async def trace_context(
|
|
self,
|
|
name: Optional[str] = None,
|
|
*,
|
|
rollout_id: Optional[str] = None,
|
|
attempt_id: Optional[str] = None,
|
|
**kwargs: Any,
|
|
) -> AsyncIterator[Any]:
|
|
"""Asynchronous implementation of the tracing context.
|
|
|
|
Args:
|
|
name: Optional operation name.
|
|
rollout_id: Optional rollout ID.
|
|
attempt_id: Optional attempt ID.
|
|
|
|
Raises:
|
|
ValueError: If store, rollout_id, and attempt_id are inconsistently provided.
|
|
RuntimeError: If Weave is not installed or client is uninitialized.
|
|
"""
|
|
|
|
if rollout_id is not None and attempt_id is not None:
|
|
self._rollout_id = rollout_id
|
|
self._attempt_id = attempt_id
|
|
elif rollout_id is None and attempt_id is None:
|
|
logger.info("No rollout_id or attempt_id provided. Skipping writing to store.")
|
|
self._rollout_id = self._attempt_id = None
|
|
else:
|
|
raise ValueError("rollout_id and attempt_id must be either both provided or both None")
|
|
|
|
await self._init_trace_context()
|
|
|
|
weave_client = self._get_weave_client()
|
|
|
|
if weave_client.server is not self._server:
|
|
logger.error(
|
|
"Weave client is not using the correct trace server. You might have multiple WeaveTracer instances running in the same process. "
|
|
f"Expected {self._server}, got {weave_client.server}"
|
|
)
|
|
|
|
arg_op = name or weave_client.project
|
|
arg_inputs: dict[str, str] = {}
|
|
if rollout_id is not None:
|
|
arg_inputs[LightningResourceAttributes.ROLLOUT_ID.value] = rollout_id
|
|
if attempt_id is not None:
|
|
arg_inputs[LightningResourceAttributes.ATTEMPT_ID.value] = attempt_id
|
|
|
|
try:
|
|
# Create a new trace call object in Weave
|
|
trace_call = weave_client.create_call( # pyright: ignore[reportUnknownMemberType]
|
|
op=arg_op, inputs=arg_inputs
|
|
)
|
|
|
|
try:
|
|
yield trace_call
|
|
# Finish trace even if no exception
|
|
weave_client.finish_call(trace_call) # pyright: ignore[reportUnknownMemberType]
|
|
except Exception as exc:
|
|
# Finish trace and log any exception
|
|
weave_client.finish_call(trace_call, exception=exc) # pyright: ignore[reportUnknownMemberType]
|
|
logger.error(f"Trace failed for rollout_id={rollout_id}, attempt_id={attempt_id}, error={exc}")
|
|
raise
|
|
|
|
finally:
|
|
try:
|
|
weave_client.flush()
|
|
# It's possible that the call end futures are from a dedicated Weave thread pool,
|
|
await asyncio.gather(*[asyncio.wrap_future(future) for future in self._complete_call_futures])
|
|
|
|
finally:
|
|
# Mandatory cleanup
|
|
self._rollout_id = None
|
|
self._attempt_id = None
|
|
self._server.clear()
|
|
|
|
def create_span(
|
|
self,
|
|
name: str,
|
|
attributes: Optional[Attributes] = None,
|
|
timestamp: Optional[float] = None,
|
|
status: Optional[TraceStatus] = None,
|
|
) -> SpanCoreFields:
|
|
if timestamp is not None:
|
|
logger.warning("Weave doesn't support customizing the start time of a call. Timestamp is ignored.")
|
|
weave_client = self._get_weave_client()
|
|
trace_call = weave_client.create_call( # pyright: ignore[reportUnknownMemberType]
|
|
op=name,
|
|
attributes=attributes,
|
|
inputs={},
|
|
)
|
|
# Immediately finish the call
|
|
weave_client.finish_call(trace_call) # pyright: ignore[reportUnknownMemberType]
|
|
# We don't wait for the call to be propagated to the server.
|
|
start_time = trace_call.started_at.timestamp() if trace_call.started_at else None
|
|
end_time = trace_call.ended_at.timestamp() if trace_call.ended_at else None
|
|
trace_status = (
|
|
TraceStatus(status_code="OK")
|
|
if trace_call.exception is None
|
|
else TraceStatus(status_code="ERROR", description=trace_call.exception)
|
|
)
|
|
return SpanCoreFields(
|
|
name=name,
|
|
attributes=flatten_attributes(trace_call.attributes or {}),
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
status=trace_status,
|
|
)
|
|
|
|
@contextmanager
|
|
def operation_context(
|
|
self,
|
|
name: str,
|
|
attributes: Optional[Attributes] = None,
|
|
start_time: Optional[float] = None,
|
|
end_time: Optional[float] = None,
|
|
) -> Iterator[SpanRecordingContext]:
|
|
if start_time is not None:
|
|
logger.warning("Weave doesn't support customizing the start time of a call. Timestamp is ignored.")
|
|
if end_time is not None:
|
|
logger.warning("Weave doesn't support customizing the end time of a call. Timestamp is ignored.")
|
|
weave_client = self._get_weave_client()
|
|
trace_call = weave_client.create_call( # pyright: ignore[reportUnknownMemberType]
|
|
op=name,
|
|
attributes=attributes,
|
|
inputs={},
|
|
)
|
|
recording_context = WeaveSpanRecordingContext(trace_call)
|
|
try:
|
|
yield recording_context
|
|
except Exception as exc:
|
|
recording_context.record_exception(exc)
|
|
raise
|
|
finally:
|
|
weave_client.finish_call(trace_call) # pyright: ignore[reportUnknownMemberType]
|
|
|
|
async def _init_trace_context(self) -> None:
|
|
"""Initialize the trace context."""
|
|
self._spans.clear()
|
|
self._calls.clear()
|
|
self._partial_call_futures.clear()
|
|
self._complete_call_futures.clear()
|
|
self._loop = weakref.ref(asyncio.get_running_loop())
|
|
|
|
def _get_weave_client(self) -> WeaveClient:
|
|
"""Get the Weave client."""
|
|
weave_client = weave.get_client()
|
|
if not weave_client:
|
|
raise RuntimeError("Weave client is not initialized. Call init_worker() first.")
|
|
return weave_client
|
|
|
|
def _ensure_loop(self) -> tuple[asyncio.AbstractEventLoop, bool]:
|
|
"""Returns a usable event loop and a boolean indicating whether it's the current running loop.
|
|
|
|
Prefer using the main loop if it's possible. Otherwise, use the current running loop.
|
|
"""
|
|
# Get the current running loop
|
|
try:
|
|
running_loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
running_loop = None
|
|
|
|
# Get the main loop, which can be a different loop
|
|
if self._loop is not None:
|
|
main_loop = self._loop()
|
|
else:
|
|
main_loop = None
|
|
|
|
if main_loop is not None:
|
|
return main_loop, id(main_loop) == id(running_loop)
|
|
elif running_loop is not None:
|
|
return running_loop, True
|
|
else:
|
|
raise RuntimeError("No running event loop found. This should not happen.")
|
|
|
|
def get_last_trace(self) -> List[Span]:
|
|
return self._spans
|
|
|
|
def partial_call_callback(self, request_content: Dict[str, Any]) -> None:
|
|
call_id = request_content.get("id")
|
|
if call_id is None:
|
|
raise ValueError("Call ID is required even for partial calls")
|
|
|
|
if call_id in self._partial_call_futures:
|
|
raise ValueError(f"Call {call_id} already has a start future")
|
|
|
|
# The callback must possibly be called from a dedicated Weave thread pool,
|
|
# but it should be executed on the main event loop.
|
|
try:
|
|
loop, is_current_loop = self._ensure_loop()
|
|
if is_current_loop:
|
|
task = loop.create_task(self.partial_call_handler(request_content))
|
|
else:
|
|
# Schedule the task on the dedicated loop
|
|
task = asyncio.run_coroutine_threadsafe(self.partial_call_handler(request_content), loop)
|
|
self._partial_call_futures[call_id] = task
|
|
except Exception as exc:
|
|
logger.exception(f"Error creating call start task: {exc}", exc_info=True)
|
|
|
|
def complete_call_callback(self, call: tsi.CallSchema) -> None:
|
|
try:
|
|
loop, is_current_loop = self._ensure_loop()
|
|
if is_current_loop:
|
|
task = loop.create_task(self.complete_call_handler(call))
|
|
else:
|
|
# Schedule the task on the dedicated loop
|
|
task = asyncio.run_coroutine_threadsafe(self.complete_call_handler(call), loop)
|
|
self._complete_call_futures.append(task)
|
|
except Exception as exc:
|
|
logger.exception(f"Error creating call finish task: {exc}", exc_info=True)
|
|
|
|
async def _get_next_sequence_id(self) -> int:
|
|
"""Get the next sequence ID for a span.
|
|
|
|
Use store to get the next sequence ID if available, otherwise use a default counter.
|
|
"""
|
|
if self._rollout_id and self._attempt_id and self._store:
|
|
return await self._store.get_next_span_sequence_id(self._rollout_id, self._attempt_id)
|
|
else:
|
|
self._default_sequence_counter += 1
|
|
return self._default_sequence_counter
|
|
|
|
async def partial_call_handler(self, request_content: Dict[str, Any]) -> int:
|
|
"""Handler called when a Weave Call starts.
|
|
|
|
Args:
|
|
request_content: The partial Weave Call object.
|
|
|
|
Returns:
|
|
The sequence ID for the call.
|
|
"""
|
|
sequence_id = await self._get_next_sequence_id()
|
|
return sequence_id
|
|
|
|
async def complete_call_handler(self, call: tsi.CallSchema) -> None:
|
|
"""Handler called when a Weave Call finishes.
|
|
|
|
Converts the call (including nested children) into spans and stores them in LightningStore.
|
|
"""
|
|
# Make sure the corresponding call_start_future is complete
|
|
if call.id in self._partial_call_futures:
|
|
sequence_id = await asyncio.wrap_future(self._partial_call_futures[call.id])
|
|
del self._partial_call_futures[call.id]
|
|
else:
|
|
# Fetch a new sequence ID as the call_start is somehow missing
|
|
if call.id in self._calls:
|
|
logger.warning(
|
|
f"Call {call.id} is already in calls. The call is already completed. Overwriting the call."
|
|
)
|
|
else:
|
|
logger.warning(f"Call {call.id} has no start future. Fetching a new sequence ID.")
|
|
sequence_id = await self._get_next_sequence_id()
|
|
|
|
self._calls[call.id] = call
|
|
|
|
span = await self.convert_call_to_span(call, self._rollout_id, self._attempt_id, sequence_id)
|
|
self._spans.append(span)
|
|
if self._store and self._rollout_id and self._attempt_id:
|
|
try:
|
|
await self._store.add_span(span)
|
|
except Exception as exc:
|
|
logger.exception(f"Error adding span to store: {exc}")
|
|
|
|
async def convert_call_to_span(
|
|
self,
|
|
call: tsi.CallSchema,
|
|
rollout_id: Optional[str] = None,
|
|
attempt_id: Optional[str] = None,
|
|
sequence_id: Optional[int] = None,
|
|
) -> Span:
|
|
"""Convert a Weave Call (with nested children) into a Agent-lightning Span.
|
|
|
|
`rollout_id` and `attempt_id` are required to attach the spans to the store.
|
|
|
|
Args:
|
|
call: The Weave Call object.
|
|
rollout_id: Optional rollout ID to attach to spans.
|
|
attempt_id: Optional attempt ID to attach to spans.
|
|
sequence_id: Optional sequence ID to attach to spans.
|
|
|
|
Returns:
|
|
List of converted spans.
|
|
"""
|
|
rollout_id = rollout_id or "rollout-dummy"
|
|
attempt_id = attempt_id or "attempt-dummy"
|
|
sequence_id = sequence_id or 0
|
|
|
|
start_ts: float = call.started_at.timestamp()
|
|
end_ts: Optional[float] = call.ended_at.timestamp() if call.ended_at else None
|
|
|
|
if call.exception:
|
|
status = TraceStatus(status_code="ERROR", description=call.exception)
|
|
else:
|
|
status = TraceStatus(status_code="OK")
|
|
|
|
attributes: Dict[str, Any] = {
|
|
LightningSpanAttributes.OPERATION_NAME.value: call.op_name,
|
|
# op_name can be possibly overridden by the attributes.
|
|
**call.attributes,
|
|
}
|
|
if call.inputs:
|
|
attributes[LightningSpanAttributes.OPERATION_INPUT.value] = call.inputs
|
|
if call.output:
|
|
attributes[LightningSpanAttributes.OPERATION_OUTPUT.value] = call.output
|
|
if call.summary:
|
|
# attributes can be possibly overridden by the summary.
|
|
attributes.update(call.summary)
|
|
if call.exception:
|
|
attributes[exception_attributes.EXCEPTION_MESSAGE] = call.exception
|
|
|
|
sanitized_attributes = sanitize_attributes(flatten_attributes(attributes, expand_leaf_lists=False))
|
|
|
|
context = SpanContext(
|
|
trace_id=call.trace_id,
|
|
span_id=call.id,
|
|
is_remote=False,
|
|
trace_state={},
|
|
)
|
|
|
|
# Get context for parent
|
|
if call.parent_id:
|
|
parent_call = self._calls.get(call.parent_id)
|
|
if parent_call:
|
|
parent_context = SpanContext(
|
|
trace_id=parent_call.trace_id,
|
|
span_id=parent_call.id,
|
|
is_remote=False,
|
|
trace_state={},
|
|
)
|
|
else:
|
|
parent_context = None
|
|
else:
|
|
parent_context = None
|
|
|
|
# Build the Span object
|
|
return Span(
|
|
rollout_id=rollout_id,
|
|
attempt_id=attempt_id,
|
|
sequence_id=sequence_id,
|
|
trace_id=call.trace_id,
|
|
span_id=call.id,
|
|
parent_id=call.parent_id,
|
|
name=op_name_to_func_name(call.op_name),
|
|
status=status,
|
|
attributes=sanitized_attributes,
|
|
events=[], # Weave calls do not generate events
|
|
links=[], # Weave calls do not generate links
|
|
start_time=start_ts,
|
|
end_time=end_ts,
|
|
context=context,
|
|
parent=parent_context,
|
|
resource=OtelResource(
|
|
attributes={
|
|
LightningResourceAttributes.ROLLOUT_ID.value: rollout_id,
|
|
LightningResourceAttributes.ATTEMPT_ID.value: attempt_id,
|
|
LightningResourceAttributes.SPAN_SEQUENCE_ID.value: sequence_id,
|
|
LightningResourceAttributes.TRACER_NAME.value: "weave",
|
|
},
|
|
schema_url="",
|
|
),
|
|
)
|