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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any