Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:44:17 +08:00

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="",
),
)