aice-observability 0.2.0__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.
Files changed (40) hide show
  1. aice_observability/__init__.py +5 -0
  2. aice_observability/ai/__init__.py +0 -0
  3. aice_observability/ai/agents.py +169 -0
  4. aice_observability/ai/evaluation.py +0 -0
  5. aice_observability/ai/instrumentation/__init__.py +0 -0
  6. aice_observability/ai/instrumentation/anthropic.py +20 -0
  7. aice_observability/ai/instrumentation/base.py +25 -0
  8. aice_observability/ai/instrumentation/gemini.py +20 -0
  9. aice_observability/ai/instrumentation/langchain.py +16 -0
  10. aice_observability/ai/instrumentation/openai.py +19 -0
  11. aice_observability/ai/instrumentation/registry.py +53 -0
  12. aice_observability/ai/instrumentation/vertexai.py +18 -0
  13. aice_observability/ai/metrics.py +216 -0
  14. aice_observability/ai/rag.py +169 -0
  15. aice_observability/ai/registry.py +0 -0
  16. aice_observability/ai/tracing.py +281 -0
  17. aice_observability/builder.py +168 -0
  18. aice_observability/core/__init__.py +0 -0
  19. aice_observability/core/config.py +69 -0
  20. aice_observability/core/exporters.py +27 -0
  21. aice_observability/core/providers.py +58 -0
  22. aice_observability/core/resources.py +28 -0
  23. aice_observability/frameworks/__init__.py +0 -0
  24. aice_observability/frameworks/django.py +7 -0
  25. aice_observability/frameworks/fastapi.py +19 -0
  26. aice_observability/frameworks/flask.py +13 -0
  27. aice_observability/integrations/__init__.py +0 -0
  28. aice_observability/integrations/asyncpg.py +20 -0
  29. aice_observability/integrations/botocore.py +17 -0
  30. aice_observability/integrations/celery.py +23 -0
  31. aice_observability/integrations/httpx.py +19 -0
  32. aice_observability/integrations/psycopg.py +17 -0
  33. aice_observability/integrations/psycopg2.py +7 -0
  34. aice_observability/integrations/redis.py +17 -0
  35. aice_observability/integrations/sqlalchemy.py +7 -0
  36. aice_observability/propagation.py +82 -0
  37. aice_observability/py.typed +0 -0
  38. aice_observability-0.2.0.dist-info/METADATA +806 -0
  39. aice_observability-0.2.0.dist-info/RECORD +40 -0
  40. aice_observability-0.2.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,5 @@
1
+ from aice_observability.builder import Observability
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ __all__ = ["Observability", "__version__"]
File without changes
@@ -0,0 +1,169 @@
1
+ """
2
+ High-level tracing helpers for AI agent orchestration.
3
+
4
+ Unlike tracing.py, which records a single provider call,
5
+ this module records the lifecycle of an AI agent.
6
+
7
+ Example hierarchy
8
+
9
+ invoke_agent
10
+ OpenAI chat
11
+ execute_tool
12
+ retrieve_documents
13
+ Gemini chat
14
+ execute_tool
15
+ Anthropic chat
16
+
17
+ Everything underneath automatically becomes part of the same trace.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from contextlib import contextmanager
23
+ from collections.abc import Iterator
24
+ from opentelemetry.trace import SpanKind
25
+
26
+ from aice_observability.ai.tracing import _span
27
+
28
+
29
+ @contextmanager
30
+ def invoke_agent(
31
+ *,
32
+ agent: str,
33
+ conversation_id: str | None = None,
34
+ session_id: str | None = None,
35
+ extra_attributes: dict | None = None,
36
+ ):
37
+ """
38
+ Records one complete execution of an AI agent.
39
+
40
+ This should wrap the outermost orchestration loop.
41
+
42
+ One invoke_agent span may contain
43
+
44
+ • multiple LLM calls
45
+ • multiple retrievals
46
+ • multiple tool executions
47
+ • sub-agents
48
+
49
+ ``session_id`` maps to ``gen_ai.session.id`` pass it only for a genuine
50
+ multi-run session. A single run is not a session; use
51
+ ``extra_attributes={"<namespace>.run.id": run_id}`` instead so dashboards
52
+ grouping by session do not conflate the two.
53
+
54
+ Example
55
+
56
+ with invoke_agent(agent="ResearchAgent"):
57
+ ...
58
+ """
59
+
60
+ attributes = {
61
+ "gen_ai.operation.name": "invoke_agent",
62
+ "gen_ai.agent.name": agent,
63
+ }
64
+
65
+ if conversation_id:
66
+ attributes["gen_ai.conversation.id"] = conversation_id
67
+
68
+ if session_id:
69
+ attributes["gen_ai.session.id"] = session_id
70
+
71
+ if extra_attributes:
72
+ attributes.update(extra_attributes)
73
+
74
+ with _span(
75
+ f"Agent {agent}",
76
+ kind=SpanKind.INTERNAL,
77
+ attributes=attributes,
78
+ ) as span:
79
+
80
+ yield span
81
+
82
+
83
+ @contextmanager
84
+ def execute_tool(
85
+ *,
86
+ tool: str,
87
+ extra_attributes: dict | None = None,
88
+ ):
89
+ """
90
+ Records execution of one tool.
91
+
92
+ Example
93
+
94
+ with execute_tool(tool="Vector Search"):
95
+ ...
96
+ """
97
+
98
+ attributes = {
99
+ "gen_ai.operation.name": "execute_tool",
100
+ "gen_ai.tool.name": tool,
101
+ }
102
+ if extra_attributes:
103
+ attributes.update(extra_attributes)
104
+
105
+ with _span(
106
+ f"Tool {tool}",
107
+ kind=SpanKind.INTERNAL,
108
+ attributes=attributes,
109
+ ) as span:
110
+
111
+ yield span
112
+
113
+
114
+ @contextmanager
115
+ def invoke_subagent(
116
+ *,
117
+ agent: str,
118
+ ):
119
+ """
120
+ Records a child agent.
121
+
122
+ This becomes a nested span beneath the current agent.
123
+ """
124
+
125
+ with _span(
126
+ f"Subagent {agent}",
127
+ kind=SpanKind.INTERNAL,
128
+ attributes={
129
+ "gen_ai.operation.name": "invoke_agent",
130
+ "gen_ai.agent.name": agent,
131
+ },
132
+ ) as span:
133
+
134
+ yield span
135
+
136
+
137
+ @contextmanager
138
+ def execute_workflow(
139
+ *,
140
+ name: str,
141
+ ):
142
+ """
143
+ Records an end-to-end AI workflow.
144
+
145
+ Example
146
+
147
+ Upload CSV
148
+ ▼
149
+ Profile data
150
+ ▼
151
+ Retrieve metadata
152
+ ▼
153
+ Run LLM
154
+ ▼
155
+ Validate
156
+ ▼
157
+ Store output
158
+ """
159
+
160
+ with _span(
161
+ f"Workflow {name}",
162
+ kind=SpanKind.INTERNAL,
163
+ attributes={
164
+ "gen_ai.operation.name": "workflow",
165
+ "workflow.name": name,
166
+ },
167
+ ) as span:
168
+
169
+ yield span
File without changes
File without changes
@@ -0,0 +1,20 @@
1
+ from anthropic import Anthropic
2
+
3
+ from aice_observability.ai.tracing import trace_provider_call
4
+
5
+
6
+ class AnthropicProvider:
7
+
8
+ def __init__(self, client: Anthropic):
9
+ self.client = client
10
+
11
+ def chat(self, **kwargs):
12
+
13
+ model = kwargs.get("model", "unknown")
14
+
15
+ return trace_provider_call(
16
+ provider="anthropic",
17
+ model=model,
18
+ operation="messages.create",
19
+ fn=lambda: self.client.messages.create(**kwargs),
20
+ )
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC
4
+ from abc import abstractmethod
5
+ from typing import Any
6
+
7
+
8
+ class LLMProvider(ABC):
9
+ """
10
+ Base class implemented by every supported LLM provider.
11
+
12
+ Implementations are intentionally very small.
13
+
14
+ Their responsibility is simply to execute the SDK call while emitting
15
+ OpenTelemetry spans that follow the GenAI semantic conventions.
16
+ """
17
+
18
+ @property
19
+ @abstractmethod
20
+ def provider(self) -> str:
21
+ ...
22
+
23
+ @abstractmethod
24
+ def chat(self, **kwargs: Any) -> Any:
25
+ ...
@@ -0,0 +1,20 @@
1
+ from google import genai
2
+
3
+ from aice_observability.ai.tracing import trace_provider_call
4
+
5
+
6
+ class GeminiProvider:
7
+
8
+ def __init__(self, client: genai.Client):
9
+ self.client = client
10
+
11
+ def generate(self, **kwargs):
12
+
13
+ model = kwargs.get("model", "unknown")
14
+
15
+ return trace_provider_call(
16
+ provider="gemini",
17
+ model=model,
18
+ operation="generate_content",
19
+ fn=lambda: self.client.models.generate_content(**kwargs),
20
+ )
@@ -0,0 +1,16 @@
1
+ from aice_observability.ai.tracing import trace_provider_call
2
+
3
+
4
+ class LangChainProvider:
5
+
6
+ def __init__(self, chain):
7
+ self.chain = chain
8
+
9
+ def invoke(self, inputs):
10
+
11
+ return trace_provider_call(
12
+ provider="langchain",
13
+ model=self.chain.__class__.__name__,
14
+ operation="invoke",
15
+ fn=lambda: self.chain.invoke(inputs),
16
+ )
@@ -0,0 +1,19 @@
1
+ from openai import OpenAI
2
+ from aice_observability.ai.tracing import trace_provider_call
3
+
4
+
5
+ class OpenAIProvider:
6
+
7
+ def __init__(self, client: OpenAI):
8
+ self.client = client
9
+
10
+ def chat(self, **kwargs):
11
+
12
+ model = kwargs.get("model", "unknown")
13
+
14
+ return trace_provider_call(
15
+ provider="openai",
16
+ model=model,
17
+ operation="chat",
18
+ fn=lambda: self.client.responses.create(**kwargs),
19
+ )
@@ -0,0 +1,53 @@
1
+ """
2
+ Registry for AI instrumentors.
3
+
4
+ Observability.start() should not know about every provider individually.
5
+
6
+ Instead it delegates to this registry.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ from aice_observability.core.config import ObservabilityConfig
14
+
15
+ from .base import AIInstrumentor
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class InstrumentationRegistry:
21
+
22
+ def __init__(self) -> None:
23
+ self._instrumentors: list[AIInstrumentor] = []
24
+
25
+ def register(
26
+ self,
27
+ instrumentor: AIInstrumentor,
28
+ ) -> None:
29
+ self._instrumentors.append(instrumentor)
30
+
31
+ def instrument_all(
32
+ self,
33
+ config: ObservabilityConfig,
34
+ ) -> None:
35
+
36
+ for instrumentor in self._instrumentors:
37
+
38
+ try:
39
+
40
+ success = instrumentor.instrument(config)
41
+
42
+ if success:
43
+ logger.info(
44
+ "Instrumented %s",
45
+ instrumentor.name,
46
+ )
47
+
48
+ except Exception:
49
+
50
+ logger.exception(
51
+ "Failed to instrument %s",
52
+ instrumentor.name,
53
+ )
@@ -0,0 +1,18 @@
1
+ from vertexai.generative_models import GenerativeModel
2
+
3
+ from aice_observability.ai.tracing import trace_provider_call
4
+
5
+
6
+ class VertexAIProvider:
7
+
8
+ def __init__(self, model: GenerativeModel):
9
+ self.model = model
10
+
11
+ def generate(self, prompt: str):
12
+
13
+ return trace_provider_call(
14
+ provider="vertexai",
15
+ model=self.model._model_name,
16
+ operation="generate_content",
17
+ fn=lambda: self.model.generate_content(prompt),
18
+ )
@@ -0,0 +1,216 @@
1
+ """
2
+ OpenTelemetry metrics for AI applications.
3
+
4
+ Unlike tracing.py, which records one span per request, this module
5
+ produces aggregated metrics suitable for Prometheus.
6
+
7
+ All metrics intentionally use LOW-CARDINALITY labels.
8
+
9
+ Do NOT attach
10
+
11
+ user IDs
12
+ workspace IDs
13
+ prompts
14
+ document IDs
15
+ conversation IDs
16
+
17
+ Those belong in traces instead.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from opentelemetry import metrics
23
+
24
+ _meter = metrics.get_meter("observability.ai")
25
+
26
+
27
+ # COUNTERS
28
+ llm_requests = _meter.create_counter(
29
+ "gen_ai.client.requests",
30
+ description="Total LLM requests",
31
+ )
32
+
33
+ llm_failures = _meter.create_counter(
34
+ "gen_ai.client.failures",
35
+ description="Failed LLM requests",
36
+ )
37
+
38
+ tool_calls = _meter.create_counter(
39
+ "gen_ai.tool.calls",
40
+ )
41
+
42
+ tool_failures = _meter.create_counter(
43
+ "gen_ai.tool.failures",
44
+ )
45
+
46
+ retrieval_requests = _meter.create_counter(
47
+ "gen_ai.retrieval.requests",
48
+ )
49
+
50
+ embedding_requests = _meter.create_counter(
51
+ "gen_ai.embedding.requests",
52
+ )
53
+
54
+ #: Budget governance events (per-run warning / per-run exceeded / tenant
55
+ #: window exhausted). A single `kind` label keeps cardinality at three;
56
+ #: tenants stay off metrics per the cardinality rule.
57
+ budget_events = _meter.create_counter(
58
+ "gen_ai.budget.events",
59
+ )
60
+
61
+
62
+ # HISTOGRAMS
63
+ llm_latency = _meter.create_histogram(
64
+ "gen_ai.client.duration",
65
+ unit="s",
66
+ )
67
+
68
+ tool_latency = _meter.create_histogram(
69
+ "gen_ai.tool.duration",
70
+ unit="s",
71
+ )
72
+
73
+ retrieval_latency = _meter.create_histogram(
74
+ "gen_ai.retrieval.duration",
75
+ unit="s",
76
+ )
77
+
78
+ input_tokens = _meter.create_histogram(
79
+ "gen_ai.usage.input_tokens",
80
+ )
81
+
82
+ output_tokens = _meter.create_histogram(
83
+ "gen_ai.usage.output_tokens",
84
+ )
85
+
86
+ total_tokens = _meter.create_histogram(
87
+ "gen_ai.usage.total_tokens",
88
+ )
89
+
90
+ cost_usd = _meter.create_histogram(
91
+ "gen_ai.usage.cost",
92
+ unit="USD",
93
+ )
94
+
95
+ retrieved_documents = _meter.create_histogram(
96
+ "gen_ai.retrieval.documents",
97
+ )
98
+
99
+
100
+ def record_llm_request(*, provider: str, model: str):
101
+ llm_requests.add(
102
+ 1,
103
+ {
104
+ "provider": provider,
105
+ "model": model,
106
+ },
107
+ )
108
+
109
+
110
+ def record_llm_failure(*, provider: str, model: str):
111
+ llm_failures.add(
112
+ 1,
113
+ {
114
+ "provider": provider,
115
+ "model": model,
116
+ },
117
+ )
118
+
119
+
120
+ def record_latency(*, provider: str, model: str, duration: float):
121
+ llm_latency.record(
122
+ duration,
123
+ {
124
+ "provider": provider,
125
+ "model": model,
126
+ },
127
+ )
128
+
129
+
130
+ def record_token_usage(*, provider: str, model: str, input: int,output: int):
131
+
132
+ input_tokens.record(
133
+ input,
134
+ {
135
+ "provider": provider,
136
+ "model": model,
137
+ },
138
+ )
139
+
140
+ output_tokens.record(
141
+ output,
142
+ {
143
+ "provider": provider,
144
+ "model": model,
145
+ },
146
+ )
147
+
148
+ total_tokens.record(
149
+ input + output,
150
+ {
151
+ "provider": provider,
152
+ "model": model,
153
+ },
154
+ )
155
+
156
+
157
+ def record_cost(*, provider: str, model: str, usd: float):
158
+ cost_usd.record(
159
+ usd,
160
+ {
161
+ "provider": provider,
162
+ "model": model,
163
+ },
164
+ )
165
+
166
+
167
+ def record_tool_call(tool: str):
168
+ tool_calls.add(
169
+ 1,
170
+ {
171
+ "tool": tool,
172
+ },
173
+ )
174
+
175
+
176
+ def record_tool_latency(*, tool: str, duration: float):
177
+ tool_latency.record(
178
+ duration,
179
+ {
180
+ "tool": tool,
181
+ },
182
+ )
183
+
184
+
185
+ def record_retrieval(*, vector_store: str, document_count: int, duration: float):
186
+
187
+ retrieval_requests.add(
188
+ 1,
189
+ {
190
+ "store": vector_store,
191
+ },
192
+ )
193
+
194
+ retrieval_latency.record(
195
+ duration,
196
+ {
197
+ "store": vector_store,
198
+ },
199
+ )
200
+
201
+ retrieved_documents.record(
202
+ document_count,
203
+ {
204
+ "store": vector_store,
205
+ },
206
+ )
207
+
208
+
209
+ def record_budget_event(kind: str):
210
+ """
211
+ One budget governance event. ``kind`` is fixed-cardinality by design:
212
+ warning, run_exceeded, tenant_exhausted anything else is dropped by the
213
+ caller, not by an allowlist here, so a new kind surfaces loudly in a
214
+ dashboard rather than silently never being counted.
215
+ """
216
+ budget_events.add(1, {"kind": kind})