Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/layerlens/instrument/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
from ._types import SpanData
from ._recorder import TraceRecorder
from ._decorator import trace
from .adapters._base import AdapterInfo, BaseAdapter

__all__ = [
"AdapterInfo",
"BaseAdapter",
"SpanData",
"TraceRecorder",
"span",
Expand Down
13 changes: 13 additions & 0 deletions src/layerlens/instrument/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,14 @@
from __future__ import annotations

from ._base import AdapterInfo, BaseAdapter
from ._registry import get, register, unregister, list_adapters, disconnect_all

__all__ = [
"AdapterInfo",
"BaseAdapter",
"register",
"unregister",
"get",
"list_adapters",
"disconnect_all",
]
36 changes: 36 additions & 0 deletions src/layerlens/instrument/adapters/_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from __future__ import annotations

import abc
from typing import Any, Dict
from dataclasses import field, dataclass


@dataclass
class AdapterInfo:
"""Metadata describing a connected adapter."""

name: str
adapter_type: str # "provider" or "framework"
version: str = "0.1.0"
connected: bool = False
metadata: Dict[str, Any] = field(default_factory=dict)


class BaseAdapter(abc.ABC):
"""Minimal interface that every adapter (provider or framework) must implement."""

@abc.abstractmethod
def connect(self, target: Any = None, **kwargs: Any) -> Any:
"""Activate instrumentation. Providers: target = SDK client. Frameworks: target = layerlens client."""

@abc.abstractmethod
def disconnect(self) -> None:
"""Deactivate instrumentation and restore originals."""

@abc.abstractmethod
def adapter_info(self) -> AdapterInfo:
"""Return metadata about this adapter."""

@property
def is_connected(self) -> bool:
return self.adapter_info().connected
46 changes: 46 additions & 0 deletions src/layerlens/instrument/adapters/_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from __future__ import annotations

import logging
from typing import Dict, List, Optional

from ._base import AdapterInfo, BaseAdapter

log: logging.Logger = logging.getLogger(__name__)

_adapters: Dict[str, BaseAdapter] = {}


def register(name: str, adapter: BaseAdapter) -> None:
"""Register an adapter. Disconnects any existing adapter with the same name."""
existing = _adapters.get(name)
if existing is not None and existing.is_connected:
existing.disconnect()
_adapters[name] = adapter


def unregister(name: str) -> Optional[BaseAdapter]:
"""Remove and disconnect an adapter. Returns the adapter or None."""
adapter = _adapters.pop(name, None)
if adapter is not None and adapter.is_connected:
adapter.disconnect()
return adapter


def get(name: str) -> Optional[BaseAdapter]:
"""Look up an adapter by name."""
return _adapters.get(name)


def list_adapters() -> List[AdapterInfo]:
"""Return info for all registered adapters."""
return [a.adapter_info() for a in _adapters.values()]


def disconnect_all() -> None:
"""Disconnect and remove all adapters."""
for adapter in _adapters.values():
try:
adapter.disconnect()
except Exception:
log.warning("Error disconnecting adapter %s", adapter, exc_info=True)
_adapters.clear()
29 changes: 27 additions & 2 deletions src/layerlens/instrument/adapters/frameworks/_base_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,40 @@
from uuid import UUID
from typing import Any, Dict, Optional

from .._base import AdapterInfo, BaseAdapter
from ..._types import SpanData
from ..._upload import upload_trace


class FrameworkTracer:
class FrameworkTracer(BaseAdapter):
"""Base class for framework adapters that manage their own span tree.

Provides run_id-based span tracking, parent-child linking, and
automatic trace upload when the root span finishes.
"""

_adapter_name: str = "framework"

def __init__(self, client: Any) -> None:
self._client = client
self._client: Any = None
self._spans: Dict[str, SpanData] = {}
self._root_run_id: Optional[str] = None
self.connect(client)

def connect(self, target: Any = None, **kwargs: Any) -> Any: # noqa: ARG002
self._client = target
return target

def disconnect(self) -> None:
self._spans.clear()
self._root_run_id = None

def adapter_info(self) -> AdapterInfo:
return AdapterInfo(
name=self._adapter_name,
adapter_type="framework",
connected=self._client is not None,
)

def _get_or_create_span(
self,
Expand Down
2 changes: 2 additions & 0 deletions src/layerlens/instrument/adapters/frameworks/langchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ def __init_subclass__(cls, **kwargs: Any) -> None:


class LangChainCallbackHandler(BaseCallbackHandler, FrameworkTracer):
_adapter_name: str = "langchain"

def __init__(self, client: Any) -> None:
BaseCallbackHandler.__init__(self)
FrameworkTracer.__init__(self, client)
Expand Down
2 changes: 2 additions & 0 deletions src/layerlens/instrument/adapters/frameworks/langgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@


class LangGraphCallbackHandler(LangChainCallbackHandler):
_adapter_name: str = "langgraph"

def on_chain_start(
self,
serialized: Optional[Dict[str, Any]],
Expand Down
68 changes: 48 additions & 20 deletions src/layerlens/instrument/adapters/providers/anthropic.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

import logging
from typing import Any, Dict, Optional
from typing import Any, Dict

from .._base import AdapterInfo, BaseAdapter
from ._base_provider import fail_llm_span, create_llm_span, finish_llm_span

log: logging.Logger = logging.getLogger(__name__)
Expand All @@ -20,20 +21,25 @@
)


class AnthropicProvider:
class AnthropicProvider(BaseAdapter):
def __init__(self) -> None:
self._client: Any = None
self._originals: Dict[str, Any] = {}

def connect_client(self, client: Any) -> Any:
self._client = client
def connect(self, target: Any = None, **kwargs: Any) -> Any: # noqa: ARG002
self._client = target

if hasattr(client, "messages"):
orig = client.messages.create
if hasattr(target, "messages"):
orig = target.messages.create
self._originals["messages.create"] = orig
client.messages.create = self._wrap_sync(orig)
target.messages.create = self._wrap_sync(orig)

return client
if hasattr(target.messages, "acreate"):
async_orig = target.messages.acreate
self._originals["messages.acreate"] = async_orig
target.messages.acreate = self._wrap_async(async_orig)

return target

def disconnect(self) -> None:
if self._client is None:
Expand All @@ -50,6 +56,13 @@ def disconnect(self) -> None:
self._client = None
self._originals.clear()

def adapter_info(self) -> AdapterInfo:
return AdapterInfo(
name="anthropic",
adapter_type="provider",
connected=self._client is not None,
)

def _wrap_sync(self, original: Any) -> Any:
def wrapped(*args: Any, **kwargs: Any) -> Any:
span, token = create_llm_span("anthropic.messages.create", kwargs, _CAPTURE_PARAMS)
Expand All @@ -65,6 +78,21 @@ def wrapped(*args: Any, **kwargs: Any) -> Any:

return wrapped

def _wrap_async(self, original: Any) -> Any:
async def wrapped(*args: Any, **kwargs: Any) -> Any:
span, token = create_llm_span("anthropic.messages.create", kwargs, _CAPTURE_PARAMS)
if span is None:
return await original(*args, **kwargs)
try:
response = await original(*args, **kwargs)
finish_llm_span(span, token, response, _extract_output, _extract_response_meta)
return response
except Exception as exc:
fail_llm_span(span, token, exc)
raise

return wrapped


def _extract_output(response: Any) -> Any:
try:
Expand Down Expand Up @@ -101,20 +129,20 @@ def _extract_response_meta(response: Any) -> Dict[str, Any]:

# --- Convenience API ---

_provider_instance: Optional[AnthropicProvider] = None


def instrument_anthropic(client: Any) -> AnthropicProvider:
global _provider_instance
if _provider_instance is not None:
_provider_instance.disconnect()
_provider_instance = AnthropicProvider()
_provider_instance.connect_client(client)
return _provider_instance
from .._registry import get, register

existing = get("anthropic")
if existing is not None:
existing.disconnect()
provider = AnthropicProvider()
provider.connect(client)
register("anthropic", provider)
return provider


def uninstrument_anthropic() -> None:
global _provider_instance
if _provider_instance is not None:
_provider_instance.disconnect()
_provider_instance = None
from .._registry import unregister

unregister("anthropic")
Loading