zooid 0.1.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.
- zooid/__init__.py +25 -0
- zooid/_version.py +1 -0
- zooid/context.py +218 -0
- zooid/dashboards/trust_center_dashboard.json +102 -0
- zooid/dashboards/voice_agent_dashboard.json +114 -0
- zooid/exporters/__init__.py +1 -0
- zooid/exporters/signoz.py +49 -0
- zooid/instrumentor.py +150 -0
- zooid/metrics.py +108 -0
- zooid/scaffold/__init__.py +6 -0
- zooid/scaffold/cli.py +94 -0
- zooid/scaffold/generator.py +69 -0
- zooid/scaffold/templates/__init__.py +1 -0
- zooid/scaffold/templates/agent.py +262 -0
- zooid/scaffold/templates/common.py +95 -0
- zooid/scaffold/templates/frameworks.py +123 -0
- zooid/scaffold/templates/project.py +362 -0
- zooid/skills/zooid-instrumentation/SKILL.md +180 -0
- zooid/trust/__init__.py +27 -0
- zooid/trust/aggregator.py +64 -0
- zooid/trust/ai_advisor.py +64 -0
- zooid/trust/context_writer.py +115 -0
- zooid/trust/mcp_server.py +163 -0
- zooid/trust/recommendations.py +71 -0
- zooid/trust/reporter.py +40 -0
- zooid/trust/rules/__init__.py +9 -0
- zooid/trust/rules/base.py +61 -0
- zooid/trust/rules/naming_rules.py +58 -0
- zooid/trust/rules/resource_rules.py +86 -0
- zooid/trust/rules/span_rules.py +125 -0
- zooid/trust/rules/voice_rules.py +50 -0
- zooid/trust/scorer.py +87 -0
- zooid/wrappers/__init__.py +14 -0
- zooid/wrappers/base_wrapper.py +68 -0
- zooid/wrappers/deepgram_wrapper.py +121 -0
- zooid/wrappers/elevenlabs_wrapper.py +125 -0
- zooid/wrappers/openai_wrapper.py +180 -0
- zooid-0.1.0.dist-info/METADATA +164 -0
- zooid-0.1.0.dist-info/RECORD +42 -0
- zooid-0.1.0.dist-info/WHEEL +4 -0
- zooid-0.1.0.dist-info/entry_points.txt +3 -0
- zooid-0.1.0.dist-info/licenses/LICENSE +21 -0
zooid/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .instrumentor import instrument_voice_app
|
|
5
|
+
from .context import (
|
|
6
|
+
voice_turn,
|
|
7
|
+
VoiceTurnContext,
|
|
8
|
+
create_traced_task,
|
|
9
|
+
traced_callback,
|
|
10
|
+
get_current_turn,
|
|
11
|
+
)
|
|
12
|
+
from .metrics import VoiceMetrics
|
|
13
|
+
from .wrappers.base_wrapper import unpatch_all
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"__version__",
|
|
17
|
+
"instrument_voice_app",
|
|
18
|
+
"voice_turn",
|
|
19
|
+
"VoiceTurnContext",
|
|
20
|
+
"create_traced_task",
|
|
21
|
+
"traced_callback",
|
|
22
|
+
"VoiceMetrics",
|
|
23
|
+
"get_current_turn",
|
|
24
|
+
"unpatch_all",
|
|
25
|
+
]
|
zooid/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
zooid/context.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextvars
|
|
5
|
+
import inspect
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, AsyncGenerator, Awaitable, Callable, Coroutine, Dict, Optional, TypeVar, cast
|
|
8
|
+
import functools
|
|
9
|
+
|
|
10
|
+
from opentelemetry import trace, context as otel_context
|
|
11
|
+
from opentelemetry.trace.status import Status, StatusCode
|
|
12
|
+
|
|
13
|
+
tracer = trace.get_tracer(__name__)
|
|
14
|
+
|
|
15
|
+
_voice_turn_var: contextvars.ContextVar[Optional["VoiceTurnContext"]] = contextvars.ContextVar(
|
|
16
|
+
"_voice_turn_var", default=None
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def get_current_turn() -> Optional[VoiceTurnContext]:
|
|
20
|
+
"""Get the current voice turn context if one exists."""
|
|
21
|
+
return _voice_turn_var.get()
|
|
22
|
+
|
|
23
|
+
class VoiceTurnContext:
|
|
24
|
+
"""Context manager for tracking voice turn timing and metrics."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
architecture_mode: str = "sequential",
|
|
29
|
+
attributes: Optional[Dict[str, Any]] = None
|
|
30
|
+
):
|
|
31
|
+
self.architecture_mode = architecture_mode
|
|
32
|
+
self.attributes = attributes or {}
|
|
33
|
+
self.start_time: float = 0.0
|
|
34
|
+
self.ttfa_ms: Optional[float] = None
|
|
35
|
+
self.stt_duration_ms: Optional[float] = None
|
|
36
|
+
self.llm_first_token_ms: Optional[float] = None
|
|
37
|
+
self.span: Optional[trace.Span] = None
|
|
38
|
+
self._token: Optional[contextvars.Token] = None
|
|
39
|
+
self._span_token: Optional[object] = None
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def get_current(cls) -> Optional["VoiceTurnContext"]:
|
|
43
|
+
"""Class method alias to get current voice turn context."""
|
|
44
|
+
return get_current_turn()
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def tracer(self) -> trace.Tracer:
|
|
48
|
+
"""Expose tracer instance."""
|
|
49
|
+
return tracer
|
|
50
|
+
|
|
51
|
+
def record_stt_complete(self) -> None:
|
|
52
|
+
if self.start_time:
|
|
53
|
+
self.stt_duration_ms = (time.monotonic() - self.start_time) * 1000.0
|
|
54
|
+
|
|
55
|
+
def mark_stt_complete(self) -> None:
|
|
56
|
+
self.record_stt_complete()
|
|
57
|
+
|
|
58
|
+
def record_llm_first_token(self) -> None:
|
|
59
|
+
if self.start_time and self.llm_first_token_ms is None:
|
|
60
|
+
self.llm_first_token_ms = (time.monotonic() - self.start_time) * 1000.0
|
|
61
|
+
|
|
62
|
+
def mark_llm_first_token(self) -> None:
|
|
63
|
+
self.record_llm_first_token()
|
|
64
|
+
|
|
65
|
+
def record_ttfa(self) -> None:
|
|
66
|
+
if self.start_time and self.ttfa_ms is None:
|
|
67
|
+
self.ttfa_ms = (time.monotonic() - self.start_time) * 1000.0
|
|
68
|
+
if self.span:
|
|
69
|
+
self.span.set_attribute("voice.time_to_first_audio_ms", self.ttfa_ms)
|
|
70
|
+
|
|
71
|
+
def mark_first_audio(self) -> None:
|
|
72
|
+
self.record_ttfa()
|
|
73
|
+
|
|
74
|
+
async def __aenter__(self) -> "VoiceTurnContext":
|
|
75
|
+
self.start_time = time.monotonic()
|
|
76
|
+
|
|
77
|
+
attrs = {
|
|
78
|
+
"voice.architecture_mode": self.architecture_mode,
|
|
79
|
+
**self.attributes
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
self.span = tracer.start_span("voice.turn", attributes=attrs)
|
|
83
|
+
self._span_token = otel_context.attach(trace.set_span_in_context(self.span))
|
|
84
|
+
self._token = _voice_turn_var.set(self)
|
|
85
|
+
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
89
|
+
try:
|
|
90
|
+
if exc_type is not None:
|
|
91
|
+
if self.span:
|
|
92
|
+
self.span.set_status(Status(StatusCode.ERROR, str(exc_val)))
|
|
93
|
+
if issubclass(exc_type, asyncio.CancelledError):
|
|
94
|
+
self.span.set_attribute("voice.interrupted", True)
|
|
95
|
+
|
|
96
|
+
if self.span:
|
|
97
|
+
if self.ttfa_ms is not None:
|
|
98
|
+
self.span.set_attribute("voice.time_to_first_audio_ms", self.ttfa_ms)
|
|
99
|
+
if self.stt_duration_ms is not None:
|
|
100
|
+
self.span.set_attribute("voice.stt_duration_ms", self.stt_duration_ms)
|
|
101
|
+
if self.llm_first_token_ms is not None:
|
|
102
|
+
self.span.set_attribute("voice.llm_first_token_ms", self.llm_first_token_ms)
|
|
103
|
+
self.span.end()
|
|
104
|
+
finally:
|
|
105
|
+
if self._span_token is not None:
|
|
106
|
+
otel_context.detach(self._span_token)
|
|
107
|
+
if self._token is not None:
|
|
108
|
+
_voice_turn_var.reset(self._token)
|
|
109
|
+
|
|
110
|
+
def voice_turn(architecture_mode: str = "sequential") -> Callable:
|
|
111
|
+
"""Decorator to wrap a function in a VoiceTurnContext."""
|
|
112
|
+
def decorator(func: Callable) -> Callable:
|
|
113
|
+
@functools.wraps(func)
|
|
114
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
115
|
+
async with VoiceTurnContext(architecture_mode=architecture_mode):
|
|
116
|
+
return await func(*args, **kwargs)
|
|
117
|
+
return wrapper
|
|
118
|
+
return decorator
|
|
119
|
+
|
|
120
|
+
T = TypeVar("T")
|
|
121
|
+
|
|
122
|
+
def create_traced_task(coro: Coroutine[Any, Any, T], name: Optional[str] = None) -> asyncio.Task[T]:
|
|
123
|
+
"""Create an asyncio task that inherits the current OpenTelemetry and Voice turn context."""
|
|
124
|
+
current_otel_context = otel_context.get_current()
|
|
125
|
+
current_voice_turn = _voice_turn_var.get()
|
|
126
|
+
|
|
127
|
+
async def wrapped_coro() -> T:
|
|
128
|
+
token = otel_context.attach(current_otel_context)
|
|
129
|
+
vt_token = _voice_turn_var.set(current_voice_turn)
|
|
130
|
+
try:
|
|
131
|
+
return await coro
|
|
132
|
+
finally:
|
|
133
|
+
otel_context.detach(token)
|
|
134
|
+
_voice_turn_var.reset(vt_token)
|
|
135
|
+
|
|
136
|
+
return asyncio.create_task(wrapped_coro(), name=name)
|
|
137
|
+
|
|
138
|
+
def traced_callback(func: Callable) -> Callable:
|
|
139
|
+
"""Decorator for preserving context in callbacks (sync or async)."""
|
|
140
|
+
current_otel_context = otel_context.get_current()
|
|
141
|
+
current_voice_turn = _voice_turn_var.get()
|
|
142
|
+
|
|
143
|
+
if inspect.iscoroutinefunction(func):
|
|
144
|
+
@functools.wraps(func)
|
|
145
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
146
|
+
token = otel_context.attach(current_otel_context)
|
|
147
|
+
vt_token = _voice_turn_var.set(current_voice_turn)
|
|
148
|
+
try:
|
|
149
|
+
return await func(*args, **kwargs)
|
|
150
|
+
finally:
|
|
151
|
+
otel_context.detach(token)
|
|
152
|
+
_voice_turn_var.reset(vt_token)
|
|
153
|
+
return async_wrapper
|
|
154
|
+
else:
|
|
155
|
+
@functools.wraps(func)
|
|
156
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
157
|
+
token = otel_context.attach(current_otel_context)
|
|
158
|
+
vt_token = _voice_turn_var.set(current_voice_turn)
|
|
159
|
+
try:
|
|
160
|
+
return func(*args, **kwargs)
|
|
161
|
+
finally:
|
|
162
|
+
otel_context.detach(token)
|
|
163
|
+
_voice_turn_var.reset(vt_token)
|
|
164
|
+
return sync_wrapper
|
|
165
|
+
|
|
166
|
+
async def traced_async_generator(
|
|
167
|
+
original: AsyncGenerator[T, None],
|
|
168
|
+
span: trace.Span,
|
|
169
|
+
on_first_item: Optional[Callable[[], None]] = None
|
|
170
|
+
) -> AsyncGenerator[T, None]:
|
|
171
|
+
"""Wrap an async generator to maintain context and track chunks."""
|
|
172
|
+
current_otel_context = otel_context.get_current()
|
|
173
|
+
current_voice_turn = _voice_turn_var.get()
|
|
174
|
+
|
|
175
|
+
chunk_count = 0
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
token = otel_context.attach(current_otel_context)
|
|
179
|
+
vt_token = _voice_turn_var.set(current_voice_turn)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
async for item in original:
|
|
183
|
+
if chunk_count == 0 and on_first_item:
|
|
184
|
+
on_first_item()
|
|
185
|
+
chunk_count += 1
|
|
186
|
+
|
|
187
|
+
# Detach before yielding to avoid leaking context to consumer
|
|
188
|
+
otel_context.detach(token)
|
|
189
|
+
_voice_turn_var.reset(vt_token)
|
|
190
|
+
|
|
191
|
+
yield item
|
|
192
|
+
|
|
193
|
+
# Re-attach after consumer returns control
|
|
194
|
+
token = otel_context.attach(current_otel_context)
|
|
195
|
+
vt_token = _voice_turn_var.set(current_voice_turn)
|
|
196
|
+
|
|
197
|
+
finally:
|
|
198
|
+
# Final detach in case we broke out of the loop
|
|
199
|
+
# Or if it completed normally, we still need to detach the re-attached context
|
|
200
|
+
try:
|
|
201
|
+
otel_context.detach(token)
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
try:
|
|
205
|
+
_voice_turn_var.reset(vt_token)
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
span.set_attribute("gen.chunk_count", chunk_count)
|
|
210
|
+
span.end()
|
|
211
|
+
except asyncio.CancelledError:
|
|
212
|
+
span.set_status(Status(StatusCode.ERROR, "Cancelled"))
|
|
213
|
+
span.end()
|
|
214
|
+
raise
|
|
215
|
+
except Exception as e:
|
|
216
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
217
|
+
span.end()
|
|
218
|
+
raise
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Zooid Trust Center",
|
|
3
|
+
"description": "Dashboard for voice agent instrumentation scoring and health",
|
|
4
|
+
"widgets": [
|
|
5
|
+
{
|
|
6
|
+
"id": "w1",
|
|
7
|
+
"title": "Instrumentation Score",
|
|
8
|
+
"description": "Average instrumentation score across services",
|
|
9
|
+
"panelType": "value",
|
|
10
|
+
"query": {
|
|
11
|
+
"metrics": [
|
|
12
|
+
{
|
|
13
|
+
"name": "instrumentation_score",
|
|
14
|
+
"aggregate": "avg",
|
|
15
|
+
"groupBy": ["service_name"]
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"layout": { "w": 4, "h": 2, "x": 0, "y": 0 }
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "w2",
|
|
23
|
+
"title": "Score by Service",
|
|
24
|
+
"description": "Detailed scores per service",
|
|
25
|
+
"panelType": "table",
|
|
26
|
+
"query": {
|
|
27
|
+
"metrics": [
|
|
28
|
+
{
|
|
29
|
+
"name": "instrumentation_score",
|
|
30
|
+
"aggregate": "avg",
|
|
31
|
+
"groupBy": ["service_name"]
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"layout": { "w": 8, "h": 4, "x": 4, "y": 0 }
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "w3",
|
|
39
|
+
"title": "Score Trend",
|
|
40
|
+
"description": "Score over time",
|
|
41
|
+
"panelType": "graph",
|
|
42
|
+
"query": {
|
|
43
|
+
"metrics": [
|
|
44
|
+
{
|
|
45
|
+
"name": "instrumentation_score",
|
|
46
|
+
"aggregate": "avg",
|
|
47
|
+
"groupBy": ["service_name"]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"layout": { "w": 6, "h": 4, "x": 0, "y": 2 }
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "w4",
|
|
55
|
+
"title": "Top Violations",
|
|
56
|
+
"description": "Most frequent instrumentation violations",
|
|
57
|
+
"panelType": "bar",
|
|
58
|
+
"query": {
|
|
59
|
+
"metrics": [
|
|
60
|
+
{
|
|
61
|
+
"name": "instrumentation_violations_total",
|
|
62
|
+
"aggregate": "sum",
|
|
63
|
+
"groupBy": ["rule_id"]
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
"layout": { "w": 6, "h": 4, "x": 6, "y": 4 }
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"id": "w5",
|
|
71
|
+
"title": "Unscorable Spans",
|
|
72
|
+
"description": "Total unscorable spans",
|
|
73
|
+
"panelType": "value",
|
|
74
|
+
"query": {
|
|
75
|
+
"metrics": [
|
|
76
|
+
{
|
|
77
|
+
"name": "instrumentation_unscorable_total",
|
|
78
|
+
"aggregate": "sum"
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
"layout": { "w": 4, "h": 2, "x": 0, "y": 6 }
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"id": "w6",
|
|
86
|
+
"title": "Category Distribution",
|
|
87
|
+
"description": "Distribution of violation categories",
|
|
88
|
+
"panelType": "pie",
|
|
89
|
+
"query": {
|
|
90
|
+
"metrics": [
|
|
91
|
+
{
|
|
92
|
+
"name": "instrumentation_violations_total",
|
|
93
|
+
"aggregate": "sum",
|
|
94
|
+
"groupBy": ["category"]
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
},
|
|
98
|
+
"layout": { "w": 4, "h": 4, "x": 4, "y": 6 }
|
|
99
|
+
}
|
|
100
|
+
],
|
|
101
|
+
"layout": []
|
|
102
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Voice Agent Dashboard",
|
|
3
|
+
"description": "Performance and health metrics for voice agents",
|
|
4
|
+
"widgets": [
|
|
5
|
+
{
|
|
6
|
+
"id": "w1",
|
|
7
|
+
"title": "TTFA (p50)",
|
|
8
|
+
"description": "Time to First Audio (50th percentile)",
|
|
9
|
+
"panelType": "value",
|
|
10
|
+
"query": {
|
|
11
|
+
"metrics": [
|
|
12
|
+
{
|
|
13
|
+
"name": "voice_time_to_first_audio_ms",
|
|
14
|
+
"aggregate": "p50"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"layout": { "w": 4, "h": 2, "x": 0, "y": 0 }
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "w2",
|
|
22
|
+
"title": "TTFA Distribution",
|
|
23
|
+
"description": "Histogram of Time to First Audio",
|
|
24
|
+
"panelType": "graph",
|
|
25
|
+
"query": {
|
|
26
|
+
"metrics": [
|
|
27
|
+
{
|
|
28
|
+
"name": "voice_ttfa_distribution",
|
|
29
|
+
"aggregate": "sum"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"layout": { "w": 8, "h": 4, "x": 4, "y": 0 }
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "w3",
|
|
37
|
+
"title": "TTFA by Architecture",
|
|
38
|
+
"description": "Time to First Audio categorized by voice architecture",
|
|
39
|
+
"panelType": "bar",
|
|
40
|
+
"query": {
|
|
41
|
+
"metrics": [
|
|
42
|
+
{
|
|
43
|
+
"name": "voice_time_to_first_audio_ms",
|
|
44
|
+
"aggregate": "avg",
|
|
45
|
+
"groupBy": ["voice_architecture"]
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"layout": { "w": 6, "h": 4, "x": 0, "y": 2 }
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"id": "w4",
|
|
53
|
+
"title": "Stage Latency",
|
|
54
|
+
"description": "Latency for STT, LLM, TTS stages",
|
|
55
|
+
"panelType": "table",
|
|
56
|
+
"query": {
|
|
57
|
+
"metrics": [
|
|
58
|
+
{
|
|
59
|
+
"name": "voice_stage_latency_ms",
|
|
60
|
+
"aggregate": "avg",
|
|
61
|
+
"groupBy": ["stage_name"]
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
"layout": { "w": 6, "h": 4, "x": 6, "y": 4 }
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"id": "w5",
|
|
69
|
+
"title": "Total Turns",
|
|
70
|
+
"description": "Total number of conversational turns",
|
|
71
|
+
"panelType": "value",
|
|
72
|
+
"query": {
|
|
73
|
+
"metrics": [
|
|
74
|
+
{
|
|
75
|
+
"name": "voice_turns_total",
|
|
76
|
+
"aggregate": "sum"
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
},
|
|
80
|
+
"layout": { "w": 4, "h": 2, "x": 0, "y": 6 }
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"id": "w6",
|
|
84
|
+
"title": "Error Rate",
|
|
85
|
+
"description": "Percentage of turns with errors",
|
|
86
|
+
"panelType": "value",
|
|
87
|
+
"query": {
|
|
88
|
+
"metrics": [
|
|
89
|
+
{
|
|
90
|
+
"name": "voice_errors_total",
|
|
91
|
+
"aggregate": "sum"
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
},
|
|
95
|
+
"layout": { "w": 4, "h": 2, "x": 4, "y": 6 }
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"id": "w7",
|
|
99
|
+
"title": "Interruptions",
|
|
100
|
+
"description": "User interruptions during generation",
|
|
101
|
+
"panelType": "value",
|
|
102
|
+
"query": {
|
|
103
|
+
"metrics": [
|
|
104
|
+
{
|
|
105
|
+
"name": "voice_interruptions_total",
|
|
106
|
+
"aggregate": "sum"
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
},
|
|
110
|
+
"layout": { "w": 4, "h": 2, "x": 8, "y": 6 }
|
|
111
|
+
}
|
|
112
|
+
],
|
|
113
|
+
"layout": []
|
|
114
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
|
|
6
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
|
7
|
+
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class SigNozConfig:
|
|
12
|
+
endpoint: str = "localhost:4317"
|
|
13
|
+
insecure: bool = True
|
|
14
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def formatted_endpoint(self) -> str:
|
|
18
|
+
if "://" in self.endpoint:
|
|
19
|
+
return self.endpoint
|
|
20
|
+
return f"http://{self.endpoint}" if self.insecure else f"https://{self.endpoint}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_signoz_exporter(
|
|
24
|
+
endpoint: str = "localhost:4317",
|
|
25
|
+
insecure: bool = True,
|
|
26
|
+
headers: Optional[Dict[str, str]] = None
|
|
27
|
+
) -> OTLPSpanExporter:
|
|
28
|
+
"""Helper function to create a SigNoz specific OTLP Span Exporter."""
|
|
29
|
+
config = SigNozConfig(endpoint=endpoint, insecure=insecure, headers=headers or {})
|
|
30
|
+
|
|
31
|
+
return OTLPSpanExporter(
|
|
32
|
+
endpoint=config.formatted_endpoint,
|
|
33
|
+
insecure=config.insecure,
|
|
34
|
+
headers=config.headers
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def create_signoz_metric_exporter(
|
|
38
|
+
endpoint: str = "localhost:4317",
|
|
39
|
+
insecure: bool = True,
|
|
40
|
+
headers: Optional[Dict[str, str]] = None
|
|
41
|
+
) -> OTLPMetricExporter:
|
|
42
|
+
"""Helper function to create a SigNoz specific OTLP Metric Exporter."""
|
|
43
|
+
config = SigNozConfig(endpoint=endpoint, insecure=insecure, headers=headers or {})
|
|
44
|
+
|
|
45
|
+
return OTLPMetricExporter(
|
|
46
|
+
endpoint=config.formatted_endpoint,
|
|
47
|
+
insecure=config.insecure,
|
|
48
|
+
headers=config.headers
|
|
49
|
+
)
|
zooid/instrumentor.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional, Any
|
|
6
|
+
|
|
7
|
+
from opentelemetry import trace
|
|
8
|
+
from opentelemetry import metrics
|
|
9
|
+
from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION, DEPLOYMENT_ENVIRONMENT
|
|
10
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
11
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
12
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
|
13
|
+
from opentelemetry.sdk.metrics import MeterProvider
|
|
14
|
+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
|
15
|
+
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
|
16
|
+
|
|
17
|
+
from .metrics import VoiceMetrics
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
class InstrumentationHandle:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
tracer_provider: TracerProvider,
|
|
26
|
+
meter_provider: MeterProvider,
|
|
27
|
+
voice_metrics: VoiceMetrics,
|
|
28
|
+
trust_processor: Optional[Any] = None,
|
|
29
|
+
context_writer: Optional[Any] = None
|
|
30
|
+
):
|
|
31
|
+
self.tracer_provider = tracer_provider
|
|
32
|
+
self.meter_provider = meter_provider
|
|
33
|
+
self.voice_metrics = voice_metrics
|
|
34
|
+
self.trust_processor = trust_processor
|
|
35
|
+
self.context_writer = context_writer
|
|
36
|
+
|
|
37
|
+
def shutdown(self) -> None:
|
|
38
|
+
"""Shutdown the providers and the background context writer."""
|
|
39
|
+
if self.context_writer is not None:
|
|
40
|
+
try:
|
|
41
|
+
self.context_writer.stop()
|
|
42
|
+
except Exception as e:
|
|
43
|
+
logger.error("Failed to stop context writer: %s", e)
|
|
44
|
+
try:
|
|
45
|
+
self.tracer_provider.shutdown()
|
|
46
|
+
self.meter_provider.shutdown()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.error("Failed to shutdown OpenTelemetry providers: %s", e)
|
|
49
|
+
|
|
50
|
+
def __enter__(self) -> "InstrumentationHandle":
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
54
|
+
self.shutdown()
|
|
55
|
+
|
|
56
|
+
def instrument_voice_app(
|
|
57
|
+
service_name: str = 'voice-agent',
|
|
58
|
+
signoz_endpoint: str = 'localhost:4317',
|
|
59
|
+
insecure: bool = True,
|
|
60
|
+
enable_trust_center: bool = True,
|
|
61
|
+
trust_center_mode: str = 'template',
|
|
62
|
+
llm_endpoint: Optional[str] = None,
|
|
63
|
+
export_interval_ms: int = 5000,
|
|
64
|
+
context_path: Optional[str] = None
|
|
65
|
+
) -> InstrumentationHandle:
|
|
66
|
+
"""Instrument a voice application with OpenTelemetry for SigNoz."""
|
|
67
|
+
|
|
68
|
+
resource = Resource.create({
|
|
69
|
+
SERVICE_NAME: service_name,
|
|
70
|
+
SERVICE_VERSION: __version__,
|
|
71
|
+
DEPLOYMENT_ENVIRONMENT: "production",
|
|
72
|
+
"telemetry.sdk.name": "zooid",
|
|
73
|
+
"telemetry.sdk.language": "python",
|
|
74
|
+
"telemetry.sdk.version": __version__,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
endpoint_url = signoz_endpoint if "://" in signoz_endpoint else f"http://{signoz_endpoint}"
|
|
78
|
+
|
|
79
|
+
# Trace setup
|
|
80
|
+
tracer_provider = TracerProvider(resource=resource)
|
|
81
|
+
span_exporter = OTLPSpanExporter(endpoint=endpoint_url, insecure=insecure)
|
|
82
|
+
span_processor = BatchSpanProcessor(span_exporter)
|
|
83
|
+
tracer_provider.add_span_processor(span_processor)
|
|
84
|
+
|
|
85
|
+
trust_processor = None
|
|
86
|
+
context_writer = None
|
|
87
|
+
if enable_trust_center:
|
|
88
|
+
try:
|
|
89
|
+
from .trust import InstrumentationScoreProcessor
|
|
90
|
+
trust_processor = InstrumentationScoreProcessor(
|
|
91
|
+
mode=trust_center_mode,
|
|
92
|
+
llm_endpoint=llm_endpoint,
|
|
93
|
+
)
|
|
94
|
+
tracer_provider.add_span_processor(trust_processor)
|
|
95
|
+
|
|
96
|
+
# Mirror the live score to .zooid/context.json for coding agents / MCP.
|
|
97
|
+
from .trust.context_writer import ContextSyncWriter, DEFAULT_PATH
|
|
98
|
+
path = context_path or os.environ.get("ZOOID_CONTEXT_PATH", DEFAULT_PATH)
|
|
99
|
+
context_writer = ContextSyncWriter(
|
|
100
|
+
trust_processor.aggregator, service_name, path=path
|
|
101
|
+
).start()
|
|
102
|
+
except ImportError:
|
|
103
|
+
logger.warning("Could not import InstrumentationScoreProcessor. Ensure it exists in .trust module.")
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.warning("Failed to start Trust Center / context writer: %s", e)
|
|
106
|
+
|
|
107
|
+
trace.set_tracer_provider(tracer_provider)
|
|
108
|
+
|
|
109
|
+
# Metrics setup
|
|
110
|
+
metric_exporter = OTLPMetricExporter(endpoint=endpoint_url, insecure=insecure)
|
|
111
|
+
metric_reader = PeriodicExportingMetricReader(
|
|
112
|
+
metric_exporter, export_interval_millis=export_interval_ms
|
|
113
|
+
)
|
|
114
|
+
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
|
115
|
+
metrics.set_meter_provider(meter_provider)
|
|
116
|
+
|
|
117
|
+
voice_metrics = VoiceMetrics(meter_provider)
|
|
118
|
+
|
|
119
|
+
# Patch libraries (ignoring ImportError for optional dependencies)
|
|
120
|
+
try:
|
|
121
|
+
from .wrappers.openai_wrapper import patch_openai
|
|
122
|
+
patch_openai()
|
|
123
|
+
except ImportError:
|
|
124
|
+
pass
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.warning("Failed to patch OpenAI: %s", e)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
from .wrappers.elevenlabs_wrapper import patch_elevenlabs
|
|
130
|
+
patch_elevenlabs()
|
|
131
|
+
except ImportError:
|
|
132
|
+
pass
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.warning("Failed to patch ElevenLabs: %s", e)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
from .wrappers.deepgram_wrapper import patch_deepgram
|
|
138
|
+
patch_deepgram()
|
|
139
|
+
except ImportError:
|
|
140
|
+
pass
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.warning("Failed to patch Deepgram: %s", e)
|
|
143
|
+
|
|
144
|
+
return InstrumentationHandle(
|
|
145
|
+
tracer_provider=tracer_provider,
|
|
146
|
+
meter_provider=meter_provider,
|
|
147
|
+
voice_metrics=voice_metrics,
|
|
148
|
+
trust_processor=trust_processor,
|
|
149
|
+
context_writer=context_writer
|
|
150
|
+
)
|