magic-lab-observer 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llm_observatory/__init__.py +32 -0
- llm_observatory/exporters/__init__.py +23 -0
- llm_observatory/exporters/otlp.py +90 -0
- llm_observatory/instrumentors/__init__.py +57 -0
- llm_observatory/instrumentors/anthropic.py +135 -0
- llm_observatory/instrumentors/langchain.py +156 -0
- llm_observatory/instrumentors/openai.py +137 -0
- llm_observatory/pricing.py +78 -0
- llm_observatory/tracer.py +379 -0
- magic_lab_observer-0.1.1.dist-info/METADATA +75 -0
- magic_lab_observer-0.1.1.dist-info/RECORD +12 -0
- magic_lab_observer-0.1.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""LLM Observatory Python SDK - Open-source LLM observability."""
|
|
2
|
+
|
|
3
|
+
from llm_observatory.exporters.otlp import OTLPExporter
|
|
4
|
+
from llm_observatory.instrumentors import instrument, uninstrument
|
|
5
|
+
from llm_observatory.pricing import MODEL_PRICING, calculate_cost, get_model_pricing
|
|
6
|
+
from llm_observatory.tracer import (
|
|
7
|
+
Span,
|
|
8
|
+
SpanStatus,
|
|
9
|
+
TokenUsage,
|
|
10
|
+
Tracer,
|
|
11
|
+
get_tracer,
|
|
12
|
+
set_tracer,
|
|
13
|
+
trace,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.1"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Tracer",
|
|
20
|
+
"Span",
|
|
21
|
+
"SpanStatus",
|
|
22
|
+
"TokenUsage",
|
|
23
|
+
"trace",
|
|
24
|
+
"get_tracer",
|
|
25
|
+
"set_tracer",
|
|
26
|
+
"instrument",
|
|
27
|
+
"uninstrument",
|
|
28
|
+
"calculate_cost",
|
|
29
|
+
"get_model_pricing",
|
|
30
|
+
"MODEL_PRICING",
|
|
31
|
+
"OTLPExporter",
|
|
32
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Exporters for sending traces to backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseExporter(ABC):
|
|
10
|
+
"""Base class for trace exporters."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def export(self, spans: list[dict[str, Any]]) -> None:
|
|
14
|
+
"""Export spans to the backend.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
spans: List of span dictionaries to export.
|
|
18
|
+
"""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def flush(self) -> None:
|
|
22
|
+
"""Flush any buffered spans."""
|
|
23
|
+
pass
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""OTLP HTTP exporter for sending traces to observatory backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from llm_observatory.exporters import BaseExporter
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OTLPExporter(BaseExporter):
|
|
16
|
+
"""Exports traces to an OTLP-compatible backend via HTTP."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
endpoint: str = "http://localhost:8000",
|
|
21
|
+
api_key: str | None = None,
|
|
22
|
+
timeout: float = 10.0,
|
|
23
|
+
max_retries: int = 3,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Initialize the exporter.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
endpoint: Backend endpoint URL.
|
|
29
|
+
api_key: Optional API key for authentication.
|
|
30
|
+
timeout: Request timeout in seconds.
|
|
31
|
+
max_retries: Maximum number of retries on failure.
|
|
32
|
+
"""
|
|
33
|
+
self.endpoint = endpoint.rstrip("/")
|
|
34
|
+
self.api_key = api_key
|
|
35
|
+
self.timeout = timeout
|
|
36
|
+
self.max_retries = max_retries
|
|
37
|
+
self._client = httpx.Client(timeout=timeout)
|
|
38
|
+
|
|
39
|
+
def export(self, spans: list[dict[str, Any]]) -> None:
|
|
40
|
+
"""Export spans to the backend.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
spans: List of span dictionaries to export.
|
|
44
|
+
"""
|
|
45
|
+
headers = {"Content-Type": "application/json"}
|
|
46
|
+
if self.api_key:
|
|
47
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
48
|
+
|
|
49
|
+
for attempt in range(self.max_retries):
|
|
50
|
+
try:
|
|
51
|
+
response = self._client.post(
|
|
52
|
+
f"{self.endpoint}/v1/traces/batch",
|
|
53
|
+
json={"spans": spans},
|
|
54
|
+
headers=headers,
|
|
55
|
+
)
|
|
56
|
+
response.raise_for_status()
|
|
57
|
+
logger.debug(f"Exported {len(spans)} spans successfully")
|
|
58
|
+
return
|
|
59
|
+
except httpx.HTTPStatusError as e:
|
|
60
|
+
logger.warning(
|
|
61
|
+
f"Export failed (attempt {attempt + 1}/{self.max_retries}): "
|
|
62
|
+
f"HTTP {e.response.status_code} - {e.response.text}"
|
|
63
|
+
)
|
|
64
|
+
if attempt == self.max_retries - 1:
|
|
65
|
+
msg = (
|
|
66
|
+
f"Export failed after {self.max_retries} attempts, "
|
|
67
|
+
f"dropping {len(spans)} spans"
|
|
68
|
+
)
|
|
69
|
+
logger.error(msg)
|
|
70
|
+
except httpx.HTTPError as e:
|
|
71
|
+
logger.warning(
|
|
72
|
+
f"Export failed (attempt {attempt + 1}/{self.max_retries}): {e}"
|
|
73
|
+
)
|
|
74
|
+
if attempt == self.max_retries - 1:
|
|
75
|
+
msg = (
|
|
76
|
+
f"Export failed after {self.max_retries} attempts, "
|
|
77
|
+
f"dropping {len(spans)} spans"
|
|
78
|
+
)
|
|
79
|
+
logger.error(msg)
|
|
80
|
+
|
|
81
|
+
def flush(self) -> None:
|
|
82
|
+
"""Flush any buffered spans."""
|
|
83
|
+
self._client.close()
|
|
84
|
+
|
|
85
|
+
def __del__(self) -> None:
|
|
86
|
+
"""Clean up the client."""
|
|
87
|
+
try:
|
|
88
|
+
self._client.close()
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Auto-instrumentation for LLM libraries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
# Store instrumentor instances for uninstrument()
|
|
8
|
+
_active_instrumentors: list[Any] = []
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def instrument(
|
|
12
|
+
openai: bool = False,
|
|
13
|
+
anthropic: bool = False,
|
|
14
|
+
langchain: bool = False,
|
|
15
|
+
**kwargs: Any,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Auto-instrument LLM libraries.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
openai: Enable OpenAI instrumentation.
|
|
21
|
+
anthropic: Enable Anthropic instrumentation.
|
|
22
|
+
langchain: Enable LangChain instrumentation.
|
|
23
|
+
**kwargs: Additional configuration options.
|
|
24
|
+
"""
|
|
25
|
+
if openai:
|
|
26
|
+
from llm_observatory.instrumentors.openai import OpenAIInstrumentor
|
|
27
|
+
|
|
28
|
+
inst = OpenAIInstrumentor()
|
|
29
|
+
inst.instrument()
|
|
30
|
+
_active_instrumentors.append(inst)
|
|
31
|
+
|
|
32
|
+
if anthropic:
|
|
33
|
+
from llm_observatory.instrumentors.anthropic import AnthropicInstrumentor
|
|
34
|
+
|
|
35
|
+
inst = AnthropicInstrumentor()
|
|
36
|
+
inst.instrument()
|
|
37
|
+
_active_instrumentors.append(inst)
|
|
38
|
+
|
|
39
|
+
if langchain:
|
|
40
|
+
try:
|
|
41
|
+
from llm_observatory.instrumentors.langchain import LangChainInstrumentor
|
|
42
|
+
|
|
43
|
+
inst = LangChainInstrumentor()
|
|
44
|
+
inst.instrument()
|
|
45
|
+
_active_instrumentors.append(inst)
|
|
46
|
+
except ImportError:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def uninstrument() -> None:
|
|
51
|
+
"""Uninstrument all active instrumentors."""
|
|
52
|
+
for inst in _active_instrumentors:
|
|
53
|
+
try:
|
|
54
|
+
inst.uninstrument()
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
_active_instrumentors.clear()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Anthropic auto-instrumentation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from llm_observatory.pricing import calculate_cost
|
|
8
|
+
from llm_observatory.tracer import Span, SpanStatus, get_tracer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AnthropicInstrumentor:
|
|
12
|
+
"""Instruments Anthropic API calls to capture traces."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._original_sync_create: Any | None = None
|
|
16
|
+
self._original_async_create: Any | None = None
|
|
17
|
+
|
|
18
|
+
def instrument(self) -> None:
|
|
19
|
+
"""Patch Anthropic to capture traces."""
|
|
20
|
+
try:
|
|
21
|
+
import anthropic
|
|
22
|
+
|
|
23
|
+
# Patch sync messages
|
|
24
|
+
self._original_sync_create = anthropic.resources.messages.Messages.create
|
|
25
|
+
anthropic.resources.messages.Messages.create = self._patched_sync_create
|
|
26
|
+
|
|
27
|
+
# Patch async messages
|
|
28
|
+
self._original_async_create = (
|
|
29
|
+
anthropic.resources.messages.AsyncMessages.create
|
|
30
|
+
)
|
|
31
|
+
anthropic.resources.messages.AsyncMessages.create = (
|
|
32
|
+
self._patched_async_create
|
|
33
|
+
)
|
|
34
|
+
except ImportError:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
def _build_span(
|
|
38
|
+
self, model: str, messages: list[dict[str, Any]]
|
|
39
|
+
) -> tuple[Span, Any]:
|
|
40
|
+
"""Build a span for an Anthropic call."""
|
|
41
|
+
tracer = get_tracer()
|
|
42
|
+
span = tracer.start_span(
|
|
43
|
+
name="anthropic.messages.create",
|
|
44
|
+
span_type="llm",
|
|
45
|
+
)
|
|
46
|
+
span.set_input({"model": model, "messages": messages})
|
|
47
|
+
span.set_attribute("gen_ai.system", "anthropic")
|
|
48
|
+
span.set_attribute("gen_ai.request.model", model)
|
|
49
|
+
return span, tracer
|
|
50
|
+
|
|
51
|
+
def _finish_span(self, span: Span, result: Any, model: str) -> None:
|
|
52
|
+
"""Extract data from result and finish span."""
|
|
53
|
+
try:
|
|
54
|
+
# Extract token usage
|
|
55
|
+
if hasattr(result, "usage"):
|
|
56
|
+
input_tokens = getattr(result.usage, "input_tokens", 0) or 0
|
|
57
|
+
output_tokens = getattr(result.usage, "output_tokens", 0) or 0
|
|
58
|
+
span.set_token_usage(input_tokens, output_tokens)
|
|
59
|
+
|
|
60
|
+
# Calculate cost
|
|
61
|
+
cost = calculate_cost(model, input_tokens, output_tokens)
|
|
62
|
+
if cost is not None:
|
|
63
|
+
span.set_cost(cost)
|
|
64
|
+
|
|
65
|
+
# Extract output
|
|
66
|
+
if hasattr(result, "content") and result.content:
|
|
67
|
+
content_blocks = []
|
|
68
|
+
for block in result.content:
|
|
69
|
+
block_data = {}
|
|
70
|
+
if hasattr(block, "type"):
|
|
71
|
+
block_data["type"] = block.type
|
|
72
|
+
if hasattr(block, "text"):
|
|
73
|
+
block_data["text"] = block.text
|
|
74
|
+
content_blocks.append(block_data)
|
|
75
|
+
span.set_output({"content": content_blocks})
|
|
76
|
+
|
|
77
|
+
span.set_attribute("response_id", getattr(result, "id", None))
|
|
78
|
+
span.set_status(SpanStatus.OK)
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
span.set_status(SpanStatus.ERROR)
|
|
82
|
+
span.set_attribute("error.message", str(e))
|
|
83
|
+
|
|
84
|
+
finally:
|
|
85
|
+
span.end()
|
|
86
|
+
|
|
87
|
+
def _patched_sync_create(self, *args: Any, **kwargs: Any) -> Any:
|
|
88
|
+
"""Patched sync create method."""
|
|
89
|
+
model = kwargs.get("model", "unknown")
|
|
90
|
+
messages = kwargs.get("messages", [])
|
|
91
|
+
|
|
92
|
+
span, _ = self._build_span(model, messages)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
result = self._original_sync_create(*args, **kwargs)
|
|
96
|
+
self._finish_span(span, result, model)
|
|
97
|
+
return result
|
|
98
|
+
except Exception as e:
|
|
99
|
+
span.set_status(SpanStatus.ERROR)
|
|
100
|
+
span.set_attribute("error.message", str(e))
|
|
101
|
+
span.end()
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
async def _patched_async_create(self, *args: Any, **kwargs: Any) -> Any:
|
|
105
|
+
"""Patched async create method."""
|
|
106
|
+
model = kwargs.get("model", "unknown")
|
|
107
|
+
messages = kwargs.get("messages", [])
|
|
108
|
+
|
|
109
|
+
span, _ = self._build_span(model, messages)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
result = await self._original_async_create(*args, **kwargs)
|
|
113
|
+
self._finish_span(span, result, model)
|
|
114
|
+
return result
|
|
115
|
+
except Exception as e:
|
|
116
|
+
span.set_status(SpanStatus.ERROR)
|
|
117
|
+
span.set_attribute("error.message", str(e))
|
|
118
|
+
span.end()
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
def uninstrument(self) -> None:
|
|
122
|
+
"""Remove instrumentation."""
|
|
123
|
+
try:
|
|
124
|
+
import anthropic
|
|
125
|
+
|
|
126
|
+
if self._original_sync_create is not None:
|
|
127
|
+
anthropic.resources.messages.Messages.create = (
|
|
128
|
+
self._original_sync_create
|
|
129
|
+
)
|
|
130
|
+
if self._original_async_create is not None:
|
|
131
|
+
anthropic.resources.messages.AsyncMessages.create = (
|
|
132
|
+
self._original_async_create
|
|
133
|
+
)
|
|
134
|
+
except ImportError:
|
|
135
|
+
pass
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""LangChain auto-instrumentation for LLM Observatory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from llm_observatory.pricing import calculate_cost
|
|
9
|
+
from llm_observatory.tracer import SpanStatus, _current_span, _get_global_tracer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LangChainInstrumentor:
|
|
13
|
+
"""Instruments LangChain callbacks to capture traces."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._patched = False
|
|
17
|
+
self._original_on_llm_start: Any = None
|
|
18
|
+
self._original_on_llm_end: Any = None
|
|
19
|
+
self._original_on_llm_error: Any = None
|
|
20
|
+
|
|
21
|
+
def instrument(self) -> None:
|
|
22
|
+
"""Enable LangChain instrumentation."""
|
|
23
|
+
if self._patched:
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
28
|
+
|
|
29
|
+
# Monkey-patch BaseCallbackHandler
|
|
30
|
+
original_on_llm_start = BaseCallbackHandler.on_llm_start
|
|
31
|
+
original_on_llm_end = BaseCallbackHandler.on_llm_end
|
|
32
|
+
original_on_llm_error = BaseCallbackHandler.on_llm_error
|
|
33
|
+
|
|
34
|
+
# Store original methods
|
|
35
|
+
self._patched = True
|
|
36
|
+
self._original_on_llm_start = original_on_llm_start
|
|
37
|
+
self._original_on_llm_end = original_on_llm_end
|
|
38
|
+
self._original_on_llm_error = original_on_llm_error
|
|
39
|
+
|
|
40
|
+
def patched_on_llm_start(
|
|
41
|
+
self_handler: Any,
|
|
42
|
+
serialized: dict[str, Any],
|
|
43
|
+
prompts: list[str],
|
|
44
|
+
*,
|
|
45
|
+
run_id: uuid.UUID | None = None,
|
|
46
|
+
**kwargs: Any,
|
|
47
|
+
) -> Any:
|
|
48
|
+
"""Patched on_llm_start that creates a span."""
|
|
49
|
+
tracer = _get_global_tracer()
|
|
50
|
+
model_id = serialized.get("id", ["unknown"])
|
|
51
|
+
model = (
|
|
52
|
+
serialized.get("name", model_id[-1])
|
|
53
|
+
if isinstance(model_id, list)
|
|
54
|
+
else "unknown"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
span = tracer.start_span(
|
|
58
|
+
f"langchain.{model}",
|
|
59
|
+
span_type="llm",
|
|
60
|
+
model=model,
|
|
61
|
+
provider="langchain",
|
|
62
|
+
)
|
|
63
|
+
span.set_input({"prompts": prompts, "serialized": serialized})
|
|
64
|
+
return original_on_llm_start(
|
|
65
|
+
self_handler, serialized, prompts,
|
|
66
|
+
run_id=run_id, **kwargs,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def patched_on_llm_end(
|
|
70
|
+
self_handler: Any,
|
|
71
|
+
response: Any,
|
|
72
|
+
*,
|
|
73
|
+
run_id: uuid.UUID | None = None,
|
|
74
|
+
**kwargs: Any,
|
|
75
|
+
) -> Any:
|
|
76
|
+
"""Patched on_llm_end that ends the span."""
|
|
77
|
+
current = _current_span.get()
|
|
78
|
+
if current:
|
|
79
|
+
# Extract token usage from LLMResult
|
|
80
|
+
if hasattr(response, "llm_output") and response.llm_output:
|
|
81
|
+
token_usage = response.llm_output.get("token_usage", {})
|
|
82
|
+
if token_usage:
|
|
83
|
+
input_tokens = token_usage.get(
|
|
84
|
+
"prompt_tokens",
|
|
85
|
+
token_usage.get("input_tokens", 0),
|
|
86
|
+
)
|
|
87
|
+
output_tokens = token_usage.get(
|
|
88
|
+
"completion_tokens",
|
|
89
|
+
token_usage.get("output_tokens", 0),
|
|
90
|
+
)
|
|
91
|
+
current.set_token_usage(input_tokens, output_tokens)
|
|
92
|
+
|
|
93
|
+
# Calculate cost
|
|
94
|
+
model = current.attributes.get("model", "unknown")
|
|
95
|
+
cost = calculate_cost(model, input_tokens, output_tokens)
|
|
96
|
+
if cost is not None:
|
|
97
|
+
current.set_cost(cost)
|
|
98
|
+
|
|
99
|
+
# Capture output
|
|
100
|
+
if hasattr(response, "generations") and response.generations:
|
|
101
|
+
generations = []
|
|
102
|
+
for gen_list in response.generations:
|
|
103
|
+
for gen in gen_list:
|
|
104
|
+
generations.append(gen.text if hasattr(gen, "text") else str(gen))
|
|
105
|
+
current.set_output({"generations": generations})
|
|
106
|
+
|
|
107
|
+
current.set_status(SpanStatus.OK)
|
|
108
|
+
current.end()
|
|
109
|
+
|
|
110
|
+
return original_on_llm_end(self_handler, response, run_id=run_id, **kwargs)
|
|
111
|
+
|
|
112
|
+
def patched_on_llm_error(
|
|
113
|
+
self_handler: Any,
|
|
114
|
+
error: BaseException,
|
|
115
|
+
*,
|
|
116
|
+
run_id: uuid.UUID | None = None,
|
|
117
|
+
**kwargs: Any,
|
|
118
|
+
) -> Any:
|
|
119
|
+
"""Patched on_llm_error that records error."""
|
|
120
|
+
current = _current_span.get()
|
|
121
|
+
if current:
|
|
122
|
+
current.set_status(SpanStatus.ERROR)
|
|
123
|
+
current.set_attribute("error.message", str(error))
|
|
124
|
+
current.end()
|
|
125
|
+
|
|
126
|
+
return original_on_llm_error(self_handler, error, run_id=run_id, **kwargs)
|
|
127
|
+
|
|
128
|
+
BaseCallbackHandler.on_llm_start = patched_on_llm_start
|
|
129
|
+
BaseCallbackHandler.on_llm_end = patched_on_llm_end
|
|
130
|
+
BaseCallbackHandler.on_llm_error = patched_on_llm_error
|
|
131
|
+
|
|
132
|
+
except ImportError:
|
|
133
|
+
# LangChain not available
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
def uninstrument(self) -> None:
|
|
137
|
+
"""Disable LangChain instrumentation."""
|
|
138
|
+
if not self._patched:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
143
|
+
|
|
144
|
+
if self._original_on_llm_start is not None:
|
|
145
|
+
BaseCallbackHandler.on_llm_start = self._original_on_llm_start
|
|
146
|
+
if self._original_on_llm_end is not None:
|
|
147
|
+
BaseCallbackHandler.on_llm_end = self._original_on_llm_end
|
|
148
|
+
if self._original_on_llm_error is not None:
|
|
149
|
+
BaseCallbackHandler.on_llm_error = self._original_on_llm_error
|
|
150
|
+
|
|
151
|
+
self._patched = False
|
|
152
|
+
self._original_on_llm_start = None
|
|
153
|
+
self._original_on_llm_end = None
|
|
154
|
+
self._original_on_llm_error = None
|
|
155
|
+
except ImportError:
|
|
156
|
+
pass
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""OpenAI auto-instrumentation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from llm_observatory.pricing import calculate_cost
|
|
8
|
+
from llm_observatory.tracer import Span, SpanStatus, get_tracer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenAIInstrumentor:
|
|
12
|
+
"""Instruments OpenAI API calls to capture traces."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._original_sync_create: Any | None = None
|
|
16
|
+
self._original_async_create: Any | None = None
|
|
17
|
+
|
|
18
|
+
def instrument(self) -> None:
|
|
19
|
+
"""Patch OpenAI to capture traces."""
|
|
20
|
+
try:
|
|
21
|
+
import openai
|
|
22
|
+
|
|
23
|
+
# Patch sync completions
|
|
24
|
+
self._original_sync_create = (
|
|
25
|
+
openai.resources.chat.completions.Completions.create
|
|
26
|
+
)
|
|
27
|
+
openai.resources.chat.completions.Completions.create = self._patched_sync_create
|
|
28
|
+
|
|
29
|
+
# Patch async completions
|
|
30
|
+
self._original_async_create = (
|
|
31
|
+
openai.resources.chat.completions.AsyncCompletions.create
|
|
32
|
+
)
|
|
33
|
+
openai.resources.chat.completions.AsyncCompletions.create = (
|
|
34
|
+
self._patched_async_create
|
|
35
|
+
)
|
|
36
|
+
except ImportError:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def _build_span(
|
|
40
|
+
self, model: str, messages: list[dict[str, Any]]
|
|
41
|
+
) -> tuple[Span, Any]:
|
|
42
|
+
"""Build a span for an OpenAI call."""
|
|
43
|
+
tracer = get_tracer()
|
|
44
|
+
span = tracer.start_span(
|
|
45
|
+
name="openai.chat.completions.create",
|
|
46
|
+
span_type="llm",
|
|
47
|
+
)
|
|
48
|
+
span.set_input({"model": model, "messages": messages})
|
|
49
|
+
span.set_attribute("gen_ai.system", "openai")
|
|
50
|
+
span.set_attribute("gen_ai.request.model", model)
|
|
51
|
+
return span, tracer
|
|
52
|
+
|
|
53
|
+
def _finish_span(self, span: Span, result: Any, model: str) -> None:
|
|
54
|
+
"""Extract data from result and finish span."""
|
|
55
|
+
try:
|
|
56
|
+
# Extract token usage
|
|
57
|
+
if hasattr(result, "usage") and result.usage:
|
|
58
|
+
input_tokens = getattr(result.usage, "prompt_tokens", 0) or 0
|
|
59
|
+
output_tokens = getattr(result.usage, "completion_tokens", 0) or 0
|
|
60
|
+
span.set_token_usage(input_tokens, output_tokens)
|
|
61
|
+
|
|
62
|
+
# Calculate cost
|
|
63
|
+
cost = calculate_cost(model, input_tokens, output_tokens)
|
|
64
|
+
if cost is not None:
|
|
65
|
+
span.set_cost(cost)
|
|
66
|
+
|
|
67
|
+
# Extract output
|
|
68
|
+
if hasattr(result, "choices") and result.choices:
|
|
69
|
+
choice = result.choices[0]
|
|
70
|
+
if hasattr(choice, "message"):
|
|
71
|
+
message = choice.message
|
|
72
|
+
output = {}
|
|
73
|
+
if hasattr(message, "content"):
|
|
74
|
+
output["content"] = message.content
|
|
75
|
+
if hasattr(message, "role"):
|
|
76
|
+
output["role"] = message.role
|
|
77
|
+
span.set_output(output)
|
|
78
|
+
|
|
79
|
+
span.set_attribute("response_id", getattr(result, "id", None))
|
|
80
|
+
span.set_status(SpanStatus.OK)
|
|
81
|
+
|
|
82
|
+
except Exception as e:
|
|
83
|
+
span.set_status(SpanStatus.ERROR)
|
|
84
|
+
span.set_attribute("error.message", str(e))
|
|
85
|
+
|
|
86
|
+
finally:
|
|
87
|
+
span.end()
|
|
88
|
+
|
|
89
|
+
def _patched_sync_create(self, *args: Any, **kwargs: Any) -> Any:
|
|
90
|
+
"""Patched sync create method."""
|
|
91
|
+
model = kwargs.get("model", "unknown")
|
|
92
|
+
messages = kwargs.get("messages", [])
|
|
93
|
+
|
|
94
|
+
span, _ = self._build_span(model, messages)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
result = self._original_sync_create(*args, **kwargs)
|
|
98
|
+
self._finish_span(span, result, model)
|
|
99
|
+
return result
|
|
100
|
+
except Exception as e:
|
|
101
|
+
span.set_status(SpanStatus.ERROR)
|
|
102
|
+
span.set_attribute("error.message", str(e))
|
|
103
|
+
span.end()
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
async def _patched_async_create(self, *args: Any, **kwargs: Any) -> Any:
|
|
107
|
+
"""Patched async create method."""
|
|
108
|
+
model = kwargs.get("model", "unknown")
|
|
109
|
+
messages = kwargs.get("messages", [])
|
|
110
|
+
|
|
111
|
+
span, _ = self._build_span(model, messages)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
result = await self._original_async_create(*args, **kwargs)
|
|
115
|
+
self._finish_span(span, result, model)
|
|
116
|
+
return result
|
|
117
|
+
except Exception as e:
|
|
118
|
+
span.set_status(SpanStatus.ERROR)
|
|
119
|
+
span.set_attribute("error.message", str(e))
|
|
120
|
+
span.end()
|
|
121
|
+
raise
|
|
122
|
+
|
|
123
|
+
def uninstrument(self) -> None:
|
|
124
|
+
"""Remove instrumentation."""
|
|
125
|
+
try:
|
|
126
|
+
import openai
|
|
127
|
+
|
|
128
|
+
if self._original_sync_create is not None:
|
|
129
|
+
openai.resources.chat.completions.Completions.create = (
|
|
130
|
+
self._original_sync_create
|
|
131
|
+
)
|
|
132
|
+
if self._original_async_create is not None:
|
|
133
|
+
openai.resources.chat.completions.AsyncCompletions.create = (
|
|
134
|
+
self._original_async_create
|
|
135
|
+
)
|
|
136
|
+
except ImportError:
|
|
137
|
+
pass
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Model pricing table for cost calculation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ModelPricing:
|
|
10
|
+
"""Pricing for a model (per 1M tokens)."""
|
|
11
|
+
|
|
12
|
+
input_per_1m: float
|
|
13
|
+
output_per_1m: float
|
|
14
|
+
currency: str = "USD"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Pricing as of July 2026 (approximate)
|
|
18
|
+
MODEL_PRICING: dict[str, ModelPricing] = {
|
|
19
|
+
# OpenAI models
|
|
20
|
+
"gpt-4o": ModelPricing(input_per_1m=2.50, output_per_1m=10.00),
|
|
21
|
+
"gpt-4o-mini": ModelPricing(input_per_1m=0.15, output_per_1m=0.60),
|
|
22
|
+
"gpt-4-turbo": ModelPricing(input_per_1m=10.00, output_per_1m=30.00),
|
|
23
|
+
"gpt-4": ModelPricing(input_per_1m=30.00, output_per_1m=60.00),
|
|
24
|
+
"gpt-3.5-turbo": ModelPricing(input_per_1m=0.50, output_per_1m=1.50),
|
|
25
|
+
"o1": ModelPricing(input_per_1m=15.00, output_per_1m=60.00),
|
|
26
|
+
"o1-mini": ModelPricing(input_per_1m=3.00, output_per_1m=12.00),
|
|
27
|
+
"o3": ModelPricing(input_per_1m=10.00, output_per_1m=40.00),
|
|
28
|
+
"o3-mini": ModelPricing(input_per_1m=1.10, output_per_1m=4.40),
|
|
29
|
+
# Anthropic models
|
|
30
|
+
"claude-4-opus": ModelPricing(input_per_1m=15.00, output_per_1m=75.00),
|
|
31
|
+
"claude-4-sonnet": ModelPricing(input_per_1m=3.00, output_per_1m=15.00),
|
|
32
|
+
"claude-3-5-sonnet": ModelPricing(input_per_1m=3.00, output_per_1m=15.00),
|
|
33
|
+
"claude-3-5-haiku": ModelPricing(input_per_1m=0.80, output_per_1m=4.00),
|
|
34
|
+
"claude-3-opus": ModelPricing(input_per_1m=15.00, output_per_1m=75.00),
|
|
35
|
+
"claude-3-sonnet": ModelPricing(input_per_1m=3.00, output_per_1m=15.00),
|
|
36
|
+
"claude-3-haiku": ModelPricing(input_per_1m=0.25, output_per_1m=1.25),
|
|
37
|
+
# Google models
|
|
38
|
+
"gemini-2.0-flash": ModelPricing(input_per_1m=0.10, output_per_1m=0.40),
|
|
39
|
+
"gemini-2.0-pro": ModelPricing(input_per_1m=1.25, output_per_1m=10.00),
|
|
40
|
+
"gemini-1.5-pro": ModelPricing(input_per_1m=1.25, output_per_1m=5.00),
|
|
41
|
+
"gemini-1.5-flash": ModelPricing(input_per_1m=0.075, output_per_1m=0.30),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def calculate_cost(
|
|
46
|
+
model: str,
|
|
47
|
+
input_tokens: int,
|
|
48
|
+
output_tokens: int,
|
|
49
|
+
) -> float | None:
|
|
50
|
+
"""Calculate cost for a model invocation.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
model: Model name (e.g., "gpt-4o").
|
|
54
|
+
input_tokens: Number of input tokens.
|
|
55
|
+
output_tokens: Number of output tokens.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Cost in USD, or None if model pricing is unknown.
|
|
59
|
+
"""
|
|
60
|
+
pricing = MODEL_PRICING.get(model)
|
|
61
|
+
if pricing is None:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
input_cost = (input_tokens / 1_000_000) * pricing.input_per_1m
|
|
65
|
+
output_cost = (output_tokens / 1_000_000) * pricing.output_per_1m
|
|
66
|
+
return input_cost + output_cost
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_model_pricing(model: str) -> ModelPricing | None:
|
|
70
|
+
"""Get pricing for a model.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
model: Model name.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
ModelPricing or None if unknown.
|
|
77
|
+
"""
|
|
78
|
+
return MODEL_PRICING.get(model)
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Core tracing engine for LLM Observatory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from contextvars import ContextVar
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Context variable for current span
|
|
20
|
+
_current_span: ContextVar[Span | None] = ContextVar("current_span", default=None)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SpanStatus(str, Enum):
|
|
24
|
+
"""Status of a span."""
|
|
25
|
+
|
|
26
|
+
OK = "ok"
|
|
27
|
+
ERROR = "error"
|
|
28
|
+
UNSET = "unset"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class TokenUsage:
|
|
33
|
+
"""Token usage statistics."""
|
|
34
|
+
|
|
35
|
+
input: int = 0
|
|
36
|
+
output: int = 0
|
|
37
|
+
total: int = 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Span:
|
|
42
|
+
"""A single trace span representing an operation."""
|
|
43
|
+
|
|
44
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
45
|
+
trace_id: str = ""
|
|
46
|
+
parent_id: str | None = None
|
|
47
|
+
name: str = ""
|
|
48
|
+
span_type: str = "generic"
|
|
49
|
+
start_time: float = field(default_factory=time.time)
|
|
50
|
+
end_time: float | None = None
|
|
51
|
+
status: SpanStatus = SpanStatus.UNSET
|
|
52
|
+
input: dict[str, Any] | None = None
|
|
53
|
+
output: dict[str, Any] | None = None
|
|
54
|
+
tokens: TokenUsage | None = None
|
|
55
|
+
cost_usd: float | None = None
|
|
56
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
57
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
def end(self, status: SpanStatus = SpanStatus.OK) -> None:
|
|
60
|
+
"""End the span."""
|
|
61
|
+
self.end_time = time.time()
|
|
62
|
+
self.status = status
|
|
63
|
+
|
|
64
|
+
def set_status(self, status: SpanStatus) -> None:
|
|
65
|
+
"""Set the span status."""
|
|
66
|
+
self.status = status
|
|
67
|
+
|
|
68
|
+
def set_attribute(self, key: str, value: Any) -> None:
|
|
69
|
+
"""Set a span attribute."""
|
|
70
|
+
self.attributes[key] = value
|
|
71
|
+
|
|
72
|
+
def set_input(self, data: dict[str, Any]) -> None:
|
|
73
|
+
"""Set the span input."""
|
|
74
|
+
self.input = data
|
|
75
|
+
|
|
76
|
+
def set_output(self, data: dict[str, Any]) -> None:
|
|
77
|
+
"""Set the span output."""
|
|
78
|
+
self.output = data
|
|
79
|
+
|
|
80
|
+
def set_token_usage(self, input_tokens: int, output_tokens: int) -> None:
|
|
81
|
+
"""Set token usage for this span."""
|
|
82
|
+
self.tokens = TokenUsage(
|
|
83
|
+
input=input_tokens,
|
|
84
|
+
output=output_tokens,
|
|
85
|
+
total=input_tokens + output_tokens,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def set_cost(self, cost_usd: float) -> None:
|
|
89
|
+
"""Set the cost in USD for this span."""
|
|
90
|
+
self.cost_usd = cost_usd
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def duration_ms(self) -> float | None:
|
|
94
|
+
"""Get duration in milliseconds."""
|
|
95
|
+
if self.end_time is None:
|
|
96
|
+
return None
|
|
97
|
+
return (self.end_time - self.start_time) * 1000
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Tracer:
|
|
101
|
+
"""Main tracer class for creating and managing traces."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
service_name: str = "llm-observatory",
|
|
106
|
+
endpoint: str | None = None,
|
|
107
|
+
api_key: str | None = None,
|
|
108
|
+
batch_size: int = 10,
|
|
109
|
+
flush_interval: float = 5.0,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Initialize the tracer.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
service_name: Name of the service being traced.
|
|
115
|
+
endpoint: Endpoint URL for the observatory backend.
|
|
116
|
+
api_key: API key for authentication.
|
|
117
|
+
batch_size: Number of spans to buffer before flushing.
|
|
118
|
+
flush_interval: Seconds between automatic flushes.
|
|
119
|
+
"""
|
|
120
|
+
self.service_name = service_name
|
|
121
|
+
self.endpoint = endpoint
|
|
122
|
+
self.api_key = api_key
|
|
123
|
+
self.batch_size = batch_size
|
|
124
|
+
self.flush_interval = flush_interval
|
|
125
|
+
self._spans: list[Span] = []
|
|
126
|
+
self._exporters: list[Any] = []
|
|
127
|
+
self._buffer: list[dict[str, Any]] = []
|
|
128
|
+
self._lock = threading.Lock()
|
|
129
|
+
self._flush_thread: threading.Thread | None = None
|
|
130
|
+
self._shutdown = False
|
|
131
|
+
|
|
132
|
+
# Auto-start flush thread if we have an endpoint
|
|
133
|
+
if endpoint:
|
|
134
|
+
self._start_flush_thread()
|
|
135
|
+
|
|
136
|
+
def _start_flush_thread(self) -> None:
|
|
137
|
+
"""Start background flush thread."""
|
|
138
|
+
if self._flush_thread is not None:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
def flush_loop() -> None:
|
|
142
|
+
while not self._shutdown:
|
|
143
|
+
time.sleep(self.flush_interval)
|
|
144
|
+
try:
|
|
145
|
+
self.flush()
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.warning(f"Flush failed: {e}")
|
|
148
|
+
|
|
149
|
+
self._flush_thread = threading.Thread(target=flush_loop, daemon=True)
|
|
150
|
+
self._flush_thread.start()
|
|
151
|
+
|
|
152
|
+
def add_exporter(self, exporter: Any) -> None:
|
|
153
|
+
"""Add an exporter to the tracer.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
exporter: Exporter instance with an export(spans) method.
|
|
157
|
+
"""
|
|
158
|
+
self._exporters.append(exporter)
|
|
159
|
+
|
|
160
|
+
def start_trace(self, name: str, **attributes: Any) -> Span:
|
|
161
|
+
"""Start a new trace.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
name: Name of the trace.
|
|
165
|
+
**attributes: Additional attributes for the trace.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
The root span of the trace.
|
|
169
|
+
"""
|
|
170
|
+
span = Span(
|
|
171
|
+
trace_id=str(uuid.uuid4()),
|
|
172
|
+
name=name,
|
|
173
|
+
span_type="trace",
|
|
174
|
+
attributes=attributes,
|
|
175
|
+
)
|
|
176
|
+
self._spans.append(span)
|
|
177
|
+
|
|
178
|
+
# Auto-export if we have exporters
|
|
179
|
+
if self._exporters:
|
|
180
|
+
span_dict = self._span_to_dict(span)
|
|
181
|
+
with self._lock:
|
|
182
|
+
self._buffer.append(span_dict)
|
|
183
|
+
if len(self._buffer) >= self.batch_size:
|
|
184
|
+
self._flush_buffer()
|
|
185
|
+
|
|
186
|
+
return span
|
|
187
|
+
|
|
188
|
+
def start_span(
|
|
189
|
+
self,
|
|
190
|
+
name: str,
|
|
191
|
+
span_type: str = "generic",
|
|
192
|
+
parent: Span | None = None,
|
|
193
|
+
**attributes: Any,
|
|
194
|
+
) -> Span:
|
|
195
|
+
"""Start a new span.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
name: Name of the span.
|
|
199
|
+
span_type: Type of span (e.g., "llm", "tool", "agent").
|
|
200
|
+
parent: Parent span (defaults to current span).
|
|
201
|
+
**attributes: Additional attributes for the span.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The new span.
|
|
205
|
+
"""
|
|
206
|
+
parent_span = parent or _current_span.get()
|
|
207
|
+
span = Span(
|
|
208
|
+
trace_id=parent_span.trace_id if parent_span else str(uuid.uuid4()),
|
|
209
|
+
parent_id=parent_span.id if parent_span else None,
|
|
210
|
+
name=name,
|
|
211
|
+
span_type=span_type,
|
|
212
|
+
attributes=attributes,
|
|
213
|
+
)
|
|
214
|
+
self._spans.append(span)
|
|
215
|
+
|
|
216
|
+
# Auto-export if we have exporters
|
|
217
|
+
if self._exporters:
|
|
218
|
+
span_dict = self._span_to_dict(span)
|
|
219
|
+
with self._lock:
|
|
220
|
+
self._buffer.append(span_dict)
|
|
221
|
+
if len(self._buffer) >= self.batch_size:
|
|
222
|
+
self._flush_buffer()
|
|
223
|
+
|
|
224
|
+
return span
|
|
225
|
+
|
|
226
|
+
def _span_to_dict(self, span: Span) -> dict[str, Any]:
|
|
227
|
+
"""Convert a span to a dictionary for export."""
|
|
228
|
+
return {
|
|
229
|
+
"id": span.id,
|
|
230
|
+
"trace_id": span.trace_id,
|
|
231
|
+
"parent_id": span.parent_id,
|
|
232
|
+
"name": span.name,
|
|
233
|
+
"span_type": span.span_type,
|
|
234
|
+
"start_time": span.start_time,
|
|
235
|
+
"end_time": span.end_time,
|
|
236
|
+
"status": span.status.value,
|
|
237
|
+
"input": span.input,
|
|
238
|
+
"output": span.output,
|
|
239
|
+
"tokens_input": span.tokens.input if span.tokens else None,
|
|
240
|
+
"tokens_output": span.tokens.output if span.tokens else None,
|
|
241
|
+
"cost_usd": span.cost_usd,
|
|
242
|
+
"metadata": span.metadata,
|
|
243
|
+
"attributes": span.attributes,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
def export(self) -> list[dict[str, Any]]:
|
|
247
|
+
"""Export all spans as dictionaries.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
List of span dictionaries.
|
|
251
|
+
"""
|
|
252
|
+
return [self._span_to_dict(span) for span in self._spans]
|
|
253
|
+
|
|
254
|
+
def flush(self) -> None:
|
|
255
|
+
"""Flush buffered spans to exporters."""
|
|
256
|
+
with self._lock:
|
|
257
|
+
if not self._buffer:
|
|
258
|
+
return
|
|
259
|
+
spans_to_export = self._buffer.copy()
|
|
260
|
+
self._buffer.clear()
|
|
261
|
+
|
|
262
|
+
for exporter in self._exporters:
|
|
263
|
+
try:
|
|
264
|
+
exporter.export(spans_to_export)
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.warning(f"Exporter failed: {e}")
|
|
267
|
+
|
|
268
|
+
def _flush_buffer(self) -> None:
|
|
269
|
+
"""Flush buffer (must hold lock)."""
|
|
270
|
+
if not self._buffer:
|
|
271
|
+
return
|
|
272
|
+
spans_to_export = self._buffer.copy()
|
|
273
|
+
self._buffer.clear()
|
|
274
|
+
|
|
275
|
+
for exporter in self._exporters:
|
|
276
|
+
try:
|
|
277
|
+
exporter.export(spans_to_export)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
logger.warning(f"Exporter failed: {e}")
|
|
280
|
+
|
|
281
|
+
def clear(self) -> None:
|
|
282
|
+
"""Clear all collected spans."""
|
|
283
|
+
self._spans.clear()
|
|
284
|
+
|
|
285
|
+
def shutdown(self) -> None:
|
|
286
|
+
"""Shutdown the tracer, flushing any remaining spans."""
|
|
287
|
+
self._shutdown = True
|
|
288
|
+
self.flush()
|
|
289
|
+
for exporter in self._exporters:
|
|
290
|
+
try:
|
|
291
|
+
exporter.flush()
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def trace(
|
|
297
|
+
name: str | None = None,
|
|
298
|
+
span_type: str = "function",
|
|
299
|
+
) -> Callable:
|
|
300
|
+
"""Decorator to trace a function (sync or async).
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
name: Name for the trace (defaults to function name).
|
|
304
|
+
span_type: Type of span.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Decorated function.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
def decorator(func: Callable) -> Callable:
|
|
311
|
+
span_name = name or func.__name__
|
|
312
|
+
|
|
313
|
+
if asyncio.iscoroutinefunction(func):
|
|
314
|
+
# Async version
|
|
315
|
+
@functools.wraps(func)
|
|
316
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
317
|
+
tracer = _get_global_tracer()
|
|
318
|
+
span = tracer.start_span(span_name, span_type=span_type)
|
|
319
|
+
token = _current_span.set(span)
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
result = await func(*args, **kwargs)
|
|
323
|
+
span.set_status(SpanStatus.OK)
|
|
324
|
+
return result
|
|
325
|
+
except Exception as e:
|
|
326
|
+
span.set_status(SpanStatus.ERROR)
|
|
327
|
+
span.set_attribute("error.message", str(e))
|
|
328
|
+
raise
|
|
329
|
+
finally:
|
|
330
|
+
span.end()
|
|
331
|
+
_current_span.reset(token)
|
|
332
|
+
|
|
333
|
+
return async_wrapper
|
|
334
|
+
else:
|
|
335
|
+
# Sync version
|
|
336
|
+
@functools.wraps(func)
|
|
337
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
338
|
+
tracer = _get_global_tracer()
|
|
339
|
+
span = tracer.start_span(span_name, span_type=span_type)
|
|
340
|
+
token = _current_span.set(span)
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
result = func(*args, **kwargs)
|
|
344
|
+
span.set_status(SpanStatus.OK)
|
|
345
|
+
return result
|
|
346
|
+
except Exception as e:
|
|
347
|
+
span.set_status(SpanStatus.ERROR)
|
|
348
|
+
span.set_attribute("error.message", str(e))
|
|
349
|
+
raise
|
|
350
|
+
finally:
|
|
351
|
+
span.end()
|
|
352
|
+
_current_span.reset(token)
|
|
353
|
+
|
|
354
|
+
return sync_wrapper
|
|
355
|
+
|
|
356
|
+
return decorator
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# Global tracer instance
|
|
360
|
+
_global_tracer: Tracer | None = None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _get_global_tracer() -> Tracer:
|
|
364
|
+
"""Get or create the global tracer instance."""
|
|
365
|
+
global _global_tracer
|
|
366
|
+
if _global_tracer is None:
|
|
367
|
+
_global_tracer = Tracer()
|
|
368
|
+
return _global_tracer
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def get_tracer() -> Tracer:
|
|
372
|
+
"""Get the global tracer instance."""
|
|
373
|
+
return _get_global_tracer()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def set_tracer(tracer: Tracer) -> None:
|
|
377
|
+
"""Set the global tracer instance."""
|
|
378
|
+
global _global_tracer
|
|
379
|
+
_global_tracer = tracer
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: magic-lab-observer
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python SDK for LLM Observatory - Open-source LLM observability
|
|
5
|
+
Project-URL: Homepage, https://github.com/Magic-Lab-Studio/observer
|
|
6
|
+
Project-URL: Repository, https://github.com/Magic-Lab-Studio/observer.git
|
|
7
|
+
Project-URL: Issues, https://github.com/Magic-Lab-Studio/observer/issues
|
|
8
|
+
Author: LLM Observatory Contributors
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: anthropic,llm,observability,openai,tracing
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: httpx>=0.27.0
|
|
20
|
+
Provides-Extra: all
|
|
21
|
+
Requires-Dist: anthropic>=0.20.0; extra == 'all'
|
|
22
|
+
Requires-Dist: langchain-core>=0.2.0; extra == 'all'
|
|
23
|
+
Requires-Dist: openai>=1.0.0; extra == 'all'
|
|
24
|
+
Provides-Extra: anthropic
|
|
25
|
+
Requires-Dist: anthropic>=0.20.0; extra == 'anthropic'
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
31
|
+
Provides-Extra: langchain
|
|
32
|
+
Requires-Dist: langchain-core>=0.2.0; extra == 'langchain'
|
|
33
|
+
Provides-Extra: openai
|
|
34
|
+
Requires-Dist: openai>=1.0.0; extra == 'openai'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# LLM Observatory Python SDK
|
|
38
|
+
|
|
39
|
+
Open-source LLM observability SDK for tracing and monitoring AI applications.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install magic-lab-observer
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
With optional integrations:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install magic-lab-observer[openai,anthropic,langchain]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
The distribution name is `magic-lab-observer`; the stable Python import remains
|
|
56
|
+
`llm_observatory`.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from llm_observatory import instrument, trace
|
|
60
|
+
|
|
61
|
+
# Auto-instrument LLM libraries
|
|
62
|
+
instrument(openai=True, anthropic=True)
|
|
63
|
+
|
|
64
|
+
# Or manually trace functions
|
|
65
|
+
@trace(name="summarize")
|
|
66
|
+
def summarize(text: str):
|
|
67
|
+
return openai.chat.completions.create(
|
|
68
|
+
model="gpt-4",
|
|
69
|
+
messages=[{"role": "user", "content": text}]
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
Apache License 2.0
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
llm_observatory/__init__.py,sha256=t_ADSn7BZjHJLqnk0gOq-VxmhYkIwMM5v63RtI58i2M,686
|
|
2
|
+
llm_observatory/pricing.py,sha256=wdfE-DmDgcsl0ZEKS1BTYbr4UY0eQuKvQyTwh2jwLTY,2756
|
|
3
|
+
llm_observatory/tracer.py,sha256=iI4hd5pSQOtV-W4AtownqG707cuR2kmDilNalVraSow,11396
|
|
4
|
+
llm_observatory/exporters/__init__.py,sha256=mmn_FJyUIXv4zVbDOWtbO372Q3_BmW5ImSfAZ0Cb0Vk,511
|
|
5
|
+
llm_observatory/exporters/otlp.py,sha256=nvJpRqkhnC7SerUXwbT85QqyOOuf6Vyv6SiYDVJHH8M,2979
|
|
6
|
+
llm_observatory/instrumentors/__init__.py,sha256=0MpihCy6fOt9WgfWXhzxD9FG6RPd0AbXIBlaGqfZaKA,1520
|
|
7
|
+
llm_observatory/instrumentors/anthropic.py,sha256=8pD7VYj9g9Pb8o-RzngjA7lnCltFikjWt63i78wxkAA,4801
|
|
8
|
+
llm_observatory/instrumentors/langchain.py,sha256=iKFzDn2ASY0T-7Ot-vXg2p5NIFxRfM4dwoK6ztD_t4k,6176
|
|
9
|
+
llm_observatory/instrumentors/openai.py,sha256=45zJTGOLq4N-hkPsnq3re7dD_Oq_S76mJLTC7BPZUxI,4878
|
|
10
|
+
magic_lab_observer-0.1.1.dist-info/METADATA,sha256=oA_RWE0h9Kl4P5xTDesWzmjoYBmZ_QyjdoHhKj1_uyE,2272
|
|
11
|
+
magic_lab_observer-0.1.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
+
magic_lab_observer-0.1.1.dist-info/RECORD,,
|