veris-ai 0.2.1__py3-none-any.whl → 1.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.
Potentially problematic release.
This version of veris-ai might be problematic. Click here for more details.
- veris_ai/__init__.py +35 -1
- veris_ai/braintrust_tracing.py +282 -0
- veris_ai/jaeger_interface/README.md +109 -0
- veris_ai/jaeger_interface/__init__.py +26 -0
- veris_ai/jaeger_interface/client.py +133 -0
- veris_ai/jaeger_interface/models.py +153 -0
- veris_ai/tool_mock.py +167 -108
- veris_ai/utils.py +270 -0
- veris_ai-1.1.0.dist-info/METADATA +448 -0
- veris_ai-1.1.0.dist-info/RECORD +12 -0
- veris_ai-0.2.1.dist-info/METADATA +0 -137
- veris_ai-0.2.1.dist-info/RECORD +0 -6
- {veris_ai-0.2.1.dist-info → veris_ai-1.1.0.dist-info}/WHEEL +0 -0
- {veris_ai-0.2.1.dist-info → veris_ai-1.1.0.dist-info}/licenses/LICENSE +0 -0
veris_ai/__init__.py
CHANGED
|
@@ -1,7 +1,41 @@
|
|
|
1
1
|
"""Veris AI Python SDK."""
|
|
2
2
|
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
3
5
|
__version__ = "0.1.0"
|
|
4
6
|
|
|
7
|
+
# Import lightweight modules that only use base dependencies
|
|
8
|
+
from .jaeger_interface import JaegerClient, SearchQuery
|
|
5
9
|
from .tool_mock import veris
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
# Lazy import for modules with heavy dependencies
|
|
12
|
+
_instrument = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def instrument(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
16
|
+
"""Lazy loader for the instrument function from braintrust_tracing.
|
|
17
|
+
|
|
18
|
+
This function requires the 'instrument' extra dependencies:
|
|
19
|
+
pip install veris-ai[instrument]
|
|
20
|
+
"""
|
|
21
|
+
global _instrument # noqa: PLW0603
|
|
22
|
+
if _instrument is None:
|
|
23
|
+
try:
|
|
24
|
+
from .braintrust_tracing import instrument as _instrument_impl # noqa: PLC0415
|
|
25
|
+
|
|
26
|
+
_instrument = _instrument_impl
|
|
27
|
+
except ImportError as e:
|
|
28
|
+
error_msg = (
|
|
29
|
+
"The 'instrument' function requires additional dependencies. "
|
|
30
|
+
"Please install them with: pip install veris-ai[instrument]"
|
|
31
|
+
)
|
|
32
|
+
raise ImportError(error_msg) from e
|
|
33
|
+
return _instrument(*args, **kwargs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"veris",
|
|
38
|
+
"JaegerClient",
|
|
39
|
+
"SearchQuery",
|
|
40
|
+
"instrument",
|
|
41
|
+
]
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Non-invasive Braintrust + Jaeger (OTEL) instrumentation helper for the `openai-agents` SDK.
|
|
2
|
+
|
|
3
|
+
Typical usage
|
|
4
|
+
-------------
|
|
5
|
+
>>> from our_sdk import braintrust_tracing
|
|
6
|
+
>>> braintrust_tracing.instrument(service_name="openai-agent")
|
|
7
|
+
|
|
8
|
+
After calling :func:`instrument`, any later call to
|
|
9
|
+
``agents.set_trace_processors([...])`` will be transparently patched so that:
|
|
10
|
+
• the list always contains a BraintrustTracingProcessor (for Braintrust UI)
|
|
11
|
+
• *and* an OpenTelemetry bridge processor that mirrors every span to the
|
|
12
|
+
global OTEL tracer provider (Jaeger by default).
|
|
13
|
+
|
|
14
|
+
Goal: deliver full OTEL compatibility while keeping the official Braintrust
|
|
15
|
+
SDK integration unchanged – **no code modifications required** besides the
|
|
16
|
+
single `instrument()` call.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
from typing import Any, cast
|
|
25
|
+
|
|
26
|
+
import wrapt # type: ignore[import-untyped, import-not-found]
|
|
27
|
+
from braintrust.wrappers.openai import (
|
|
28
|
+
BraintrustTracingProcessor, # type: ignore[import-untyped, import-not-found]
|
|
29
|
+
)
|
|
30
|
+
from opentelemetry import context as otel_context # type: ignore[import-untyped, import-not-found]
|
|
31
|
+
from opentelemetry import trace # type: ignore[import-untyped, import-not-found]
|
|
32
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
|
33
|
+
OTLPSpanExporter, # type: ignore[import-untyped, import-not-found]
|
|
34
|
+
)
|
|
35
|
+
from opentelemetry.sdk.resources import ( # type: ignore[import-untyped, import-not-found]
|
|
36
|
+
SERVICE_NAME,
|
|
37
|
+
Resource,
|
|
38
|
+
)
|
|
39
|
+
from opentelemetry.sdk.trace import TracerProvider # type: ignore[import-untyped, import-not-found]
|
|
40
|
+
from opentelemetry.sdk.trace.export import (
|
|
41
|
+
BatchSpanProcessor, # type: ignore[import-untyped, import-not-found]
|
|
42
|
+
)
|
|
43
|
+
from opentelemetry.trace import SpanKind # type: ignore[import-untyped, import-not-found]
|
|
44
|
+
|
|
45
|
+
from veris_ai.tool_mock import _session_id_context
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Optional import of *agents* – we fail lazily at runtime if missing.
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
try:
|
|
51
|
+
import agents # type: ignore[import-untyped] # noqa: TC002
|
|
52
|
+
from agents import TracingProcessor # type: ignore[import-untyped]
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
from agents.tracing import get_trace_provider # type: ignore[import-untyped]
|
|
56
|
+
except ImportError:
|
|
57
|
+
# Fallback for newer versions that have GLOBAL_TRACE_PROVIDER instead
|
|
58
|
+
from agents.tracing import ( # type: ignore[import-untyped, attr-defined, import-not-found]
|
|
59
|
+
GLOBAL_TRACE_PROVIDER, # type: ignore[import-untyped, attr-defined, import-not-found]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
get_trace_provider = lambda: GLOBAL_TRACE_PROVIDER # type: ignore[no-any-return] # noqa: E731
|
|
63
|
+
except ModuleNotFoundError as exc: # pragma: no cover
|
|
64
|
+
_IMPORT_ERR: ModuleNotFoundError | None = exc
|
|
65
|
+
TracingProcessor = object # type: ignore[assignment, misc]
|
|
66
|
+
get_trace_provider = None # type: ignore[assignment]
|
|
67
|
+
else:
|
|
68
|
+
_IMPORT_ERR = None
|
|
69
|
+
|
|
70
|
+
__all__ = ["instrument"]
|
|
71
|
+
|
|
72
|
+
logger = logging.getLogger(__name__)
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Internal helper – OTEL bridge processor
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AgentsOTELBridgeProcessor(TracingProcessor): # type: ignore[misc]
|
|
80
|
+
"""Mirrors every Agents span into a dedicated OTEL tracer provider."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
braintrust_processor: BraintrustTracingProcessor,
|
|
85
|
+
*,
|
|
86
|
+
service_name: str, # noqa: ARG002
|
|
87
|
+
tracer_provider: trace.TracerProvider,
|
|
88
|
+
) -> None: # noqa: D401,E501
|
|
89
|
+
self._braintrust = braintrust_processor
|
|
90
|
+
self._tracer = tracer_provider.get_tracer(__name__)
|
|
91
|
+
self._otel_spans: dict[str, trace.Span] = {}
|
|
92
|
+
self._provider = tracer_provider
|
|
93
|
+
|
|
94
|
+
# ----------------------------- utils ---------------------------------
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _flatten(prefix: str, obj: Any, out: dict[str, Any]) -> None: # noqa: PLR0911, ANN401
|
|
97
|
+
"""Flatten complex objects into OTEL-compatible primitives."""
|
|
98
|
+
if isinstance(obj, dict):
|
|
99
|
+
for k, v in obj.items():
|
|
100
|
+
AgentsOTELBridgeProcessor._flatten(f"{prefix}.{k}" if prefix else str(k), v, out)
|
|
101
|
+
elif isinstance(obj, str | int | float | bool) or obj is None:
|
|
102
|
+
out[prefix] = obj
|
|
103
|
+
elif isinstance(obj, list | tuple):
|
|
104
|
+
try:
|
|
105
|
+
if all(isinstance(i, str | int | float | bool) or i is None for i in obj):
|
|
106
|
+
out[prefix] = list(obj)
|
|
107
|
+
else:
|
|
108
|
+
out[prefix] = json.dumps(obj, default=str)
|
|
109
|
+
except Exception: # pragma: no cover – defensive
|
|
110
|
+
out[prefix] = json.dumps(obj, default=str)
|
|
111
|
+
else:
|
|
112
|
+
out[prefix] = str(obj)
|
|
113
|
+
|
|
114
|
+
def _log_data_attributes(self, span_obj: agents.tracing.Span) -> dict[str, Any]: # type: ignore[name-defined]
|
|
115
|
+
data = self._braintrust._log_data(span_obj) # pyright: ignore[reportPrivateUsage] # noqa: SLF001
|
|
116
|
+
flat: dict[str, Any] = {}
|
|
117
|
+
self._flatten("bt", data, flat)
|
|
118
|
+
|
|
119
|
+
# Add session_id if available
|
|
120
|
+
session_id = _session_id_context.get()
|
|
121
|
+
if session_id:
|
|
122
|
+
flat["veris.session_id"] = session_id
|
|
123
|
+
|
|
124
|
+
return {k: v for k, v in flat.items() if v is not None}
|
|
125
|
+
|
|
126
|
+
# --------------------- Agents lifecycle hooks ------------------------
|
|
127
|
+
def on_trace_start(self, trace_obj: Any) -> None: # noqa: ANN401, D102
|
|
128
|
+
# Get session_id at trace start
|
|
129
|
+
session_id = _session_id_context.get()
|
|
130
|
+
attributes = {"veris.session_id": session_id} if session_id else {}
|
|
131
|
+
|
|
132
|
+
otel_span = self._tracer.start_span(
|
|
133
|
+
name=trace_obj.name or "agent-trace",
|
|
134
|
+
kind=SpanKind.INTERNAL,
|
|
135
|
+
attributes=attributes,
|
|
136
|
+
)
|
|
137
|
+
self._otel_spans[trace_obj.trace_id] = otel_span
|
|
138
|
+
logger.info(f"VERIS AI BraintrustTracingProcessor: on_trace_start: {trace_obj.trace_id}")
|
|
139
|
+
|
|
140
|
+
def on_trace_end(self, trace_obj: Any) -> None: # noqa: ANN401, D102
|
|
141
|
+
span = self._otel_spans.pop(trace_obj.trace_id, None)
|
|
142
|
+
if span:
|
|
143
|
+
span.end()
|
|
144
|
+
|
|
145
|
+
def on_span_start(self, span: Any) -> None: # noqa: ANN401, D102
|
|
146
|
+
parent_otel = (
|
|
147
|
+
self._otel_spans.get(span.parent_id)
|
|
148
|
+
if span.parent_id
|
|
149
|
+
else self._otel_spans.get(span.trace_id)
|
|
150
|
+
)
|
|
151
|
+
parent_ctx = (
|
|
152
|
+
trace.set_span_in_context(parent_otel) if parent_otel else otel_context.get_current()
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Get session_id at span start
|
|
156
|
+
session_id = _session_id_context.get()
|
|
157
|
+
attributes = {"veris.session_id": session_id} if session_id else {}
|
|
158
|
+
|
|
159
|
+
child = self._tracer.start_span(
|
|
160
|
+
name=span.span_data.__class__.__name__,
|
|
161
|
+
context=parent_ctx,
|
|
162
|
+
kind=SpanKind.INTERNAL,
|
|
163
|
+
attributes=attributes,
|
|
164
|
+
)
|
|
165
|
+
self._otel_spans[span.span_id] = child
|
|
166
|
+
logger.info(f"VERIS AI BraintrustTracingProcessor: on_span_start: {span.span_id}")
|
|
167
|
+
|
|
168
|
+
def on_span_end(self, span: Any) -> None: # noqa: ANN401, D102
|
|
169
|
+
child = self._otel_spans.pop(span.span_id, None)
|
|
170
|
+
logger.info(f"VERIS AI BraintrustTracingProcessor: on_span_end: {span.span_id}")
|
|
171
|
+
if child:
|
|
172
|
+
for k, v in self._log_data_attributes(span).items():
|
|
173
|
+
try:
|
|
174
|
+
child.set_attribute(k, v)
|
|
175
|
+
except Exception: # pragma: no cover – bad value type # noqa: S112
|
|
176
|
+
continue
|
|
177
|
+
child.end()
|
|
178
|
+
|
|
179
|
+
# --------------------- house-keeping ---------------------------------
|
|
180
|
+
def shutdown(self) -> None: # noqa: D401
|
|
181
|
+
provider = cast("TracerProvider", self._provider)
|
|
182
|
+
provider.shutdown() # type: ignore[attr-defined]
|
|
183
|
+
|
|
184
|
+
def force_flush(self) -> None: # noqa: D401
|
|
185
|
+
provider = cast("TracerProvider", self._provider)
|
|
186
|
+
provider.force_flush() # type: ignore[attr-defined]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# Public entry point
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
_PATCHED: bool = False # ensure idempotent patching
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def instrument(
|
|
197
|
+
*,
|
|
198
|
+
service_name: str | None = None,
|
|
199
|
+
otlp_endpoint: str | None = None,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Bootstrap Braintrust + OTEL instrumentation and patch Agents SDK.
|
|
202
|
+
|
|
203
|
+
Invoke once at any point before `set_trace_processors` is called.
|
|
204
|
+
"""
|
|
205
|
+
global _PATCHED # noqa: PLW0603
|
|
206
|
+
if _PATCHED:
|
|
207
|
+
return # already done
|
|
208
|
+
|
|
209
|
+
if _IMPORT_ERR is not None or get_trace_provider is None: # pragma: no cover
|
|
210
|
+
error_msg = "The `agents` package is required but not installed"
|
|
211
|
+
raise RuntimeError(error_msg) from _IMPORT_ERR
|
|
212
|
+
|
|
213
|
+
# ------------------ 0. Validate inputs -----------------------------
|
|
214
|
+
# Resolve service name ─ explicit argument → env var → error
|
|
215
|
+
if not service_name or not str(service_name).strip():
|
|
216
|
+
service_name = os.getenv("VERIS_SERVICE_NAME")
|
|
217
|
+
|
|
218
|
+
if not service_name or not str(service_name).strip():
|
|
219
|
+
error_msg = (
|
|
220
|
+
"`service_name` must be provided either as an argument or via the "
|
|
221
|
+
"VERIS_SERVICE_NAME environment variable"
|
|
222
|
+
)
|
|
223
|
+
raise ValueError(error_msg)
|
|
224
|
+
|
|
225
|
+
# Resolve OTLP endpoint ─ explicit argument → env var → error
|
|
226
|
+
if not otlp_endpoint or not str(otlp_endpoint).strip():
|
|
227
|
+
otlp_endpoint = os.getenv("VERIS_OTLP_ENDPOINT")
|
|
228
|
+
|
|
229
|
+
if not otlp_endpoint or not str(otlp_endpoint).strip():
|
|
230
|
+
error_msg = (
|
|
231
|
+
"`otlp_endpoint` must be provided either as an argument or via the "
|
|
232
|
+
"VERIS_OTLP_ENDPOINT environment variable"
|
|
233
|
+
)
|
|
234
|
+
raise ValueError(error_msg)
|
|
235
|
+
|
|
236
|
+
logger.info(f"service_name: {service_name}")
|
|
237
|
+
logger.info(f"otlp_endpoint: {otlp_endpoint}")
|
|
238
|
+
|
|
239
|
+
# ------------------ 1. Configure OTEL provider ---------------------
|
|
240
|
+
# We create our own provider instance and do NOT set it globally.
|
|
241
|
+
# This avoids conflicts with any other OTEL setup in the application.
|
|
242
|
+
otel_provider = TracerProvider(resource=Resource.create({SERVICE_NAME: service_name}))
|
|
243
|
+
otel_provider.add_span_processor(
|
|
244
|
+
BatchSpanProcessor(OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True)),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# ------------------ 2. Define wrapper for patching -------------------
|
|
248
|
+
def _wrapper(wrapped: Any, instance: Any, args: Any, kwargs: Any) -> Any: # noqa: ANN401, ARG001
|
|
249
|
+
"""This function wraps `TraceProvider.set_processors`."""
|
|
250
|
+
processors = args[0] if args else []
|
|
251
|
+
|
|
252
|
+
# Find the user's Braintrust processor to pass to our bridge.
|
|
253
|
+
bt_processor = next(
|
|
254
|
+
(p for p in processors if isinstance(p, BraintrustTracingProcessor)), None
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# If no Braintrust processor is present, our bridge is useless.
|
|
258
|
+
# Also, if a bridge is already there, don't add another one.
|
|
259
|
+
has_bridge = any(isinstance(p, AgentsOTELBridgeProcessor) for p in processors)
|
|
260
|
+
if not bt_processor or has_bridge:
|
|
261
|
+
return wrapped(*args, **kwargs)
|
|
262
|
+
|
|
263
|
+
# Create the bridge and add it to the list of processors.
|
|
264
|
+
bridge = AgentsOTELBridgeProcessor(
|
|
265
|
+
bt_processor,
|
|
266
|
+
service_name=service_name,
|
|
267
|
+
tracer_provider=otel_provider,
|
|
268
|
+
)
|
|
269
|
+
new_processors = list(processors) + [bridge]
|
|
270
|
+
|
|
271
|
+
# Call the original function with the augmented list.
|
|
272
|
+
new_args = (new_processors,) + args[1:]
|
|
273
|
+
logger.info(f"VERIS AI BraintrustTracingProcessor: {new_args}")
|
|
274
|
+
return wrapped(*new_args, **kwargs)
|
|
275
|
+
|
|
276
|
+
# ------------------ 3. Patch the provider instance -------------------
|
|
277
|
+
# This is more robust than patching the function, as it's independent
|
|
278
|
+
# of how the user imports `set_trace_processors`.
|
|
279
|
+
provider_instance = get_trace_provider()
|
|
280
|
+
wrapt.wrap_function_wrapper(provider_instance, "set_processors", _wrapper)
|
|
281
|
+
|
|
282
|
+
_PATCHED = True
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Jaeger Interface
|
|
2
|
+
|
|
3
|
+
This sub-package ships a **thin synchronous wrapper** around the
|
|
4
|
+
[Jaeger Query Service](https://www.jaegertracing.io/docs/) HTTP API so
|
|
5
|
+
that you can **search for and retrieve traces** directly from Python
|
|
6
|
+
with minimal boilerplate.
|
|
7
|
+
|
|
8
|
+
> The client relies on `requests` (already included in the SDK’s
|
|
9
|
+
> dependencies) and uses *pydantic* for full type-safety.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
`veris-ai` already lists both `requests` and `pydantic` as hard
|
|
16
|
+
requirements, so **no additional dependencies are required**.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install veris-ai
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Quick-start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from veris_ai.jaeger_interface import JaegerClient, SearchQuery
|
|
28
|
+
import json
|
|
29
|
+
from veris_ai.jaeger_interface.models import Trace
|
|
30
|
+
# Replace with the URL of your Jaeger Query Service instance
|
|
31
|
+
client = JaegerClient("http://localhost:16686")
|
|
32
|
+
|
|
33
|
+
# --- 1. Search traces --------------------------------------------------
|
|
34
|
+
resp = client.search(
|
|
35
|
+
SearchQuery(
|
|
36
|
+
service="veris-agent",
|
|
37
|
+
limit=3,
|
|
38
|
+
operation="CustomSpanData",
|
|
39
|
+
tags={"bt.metadata.session_id":"oRL1_IhMP3s7T7mYrixCW"}
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# save to json
|
|
44
|
+
with open("resp.json", "w") as f:
|
|
45
|
+
f.write(resp.model_dump_json(indent=2))
|
|
46
|
+
|
|
47
|
+
# Guard clause
|
|
48
|
+
if not resp or not resp.data:
|
|
49
|
+
print("No data found")
|
|
50
|
+
exit(1)
|
|
51
|
+
|
|
52
|
+
# Print trace ids
|
|
53
|
+
for trace in resp.data:
|
|
54
|
+
if isinstance(trace, Trace):
|
|
55
|
+
print("TRACE ID:", trace.traceID, len(trace.spans), "spans")
|
|
56
|
+
|
|
57
|
+
# --- 2. Retrieve a specific trace -------------------------------------
|
|
58
|
+
if isinstance(resp.data, list):
|
|
59
|
+
trace_id = resp.data[0].traceID
|
|
60
|
+
else:
|
|
61
|
+
trace_id = resp.data.traceID
|
|
62
|
+
|
|
63
|
+
detailed = client.get_trace(trace_id)
|
|
64
|
+
# save detailed to json
|
|
65
|
+
with open("detailed.json", "w") as f:
|
|
66
|
+
f.write(detailed.model_dump_json(indent=2))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## API Reference
|
|
72
|
+
|
|
73
|
+
### `JaegerClient`
|
|
74
|
+
|
|
75
|
+
| Method | Description |
|
|
76
|
+
| -------- | ----------- |
|
|
77
|
+
| `search(query: SearchQuery) -> SearchResponse` | Search for traces matching the given parameters (wrapper around `/api/traces`). |
|
|
78
|
+
| `get_trace(trace_id: str) -> GetTraceResponse` | Fetch a single trace by ID (wrapper around `/api/traces/{id}`). |
|
|
79
|
+
|
|
80
|
+
### `SearchQuery`
|
|
81
|
+
|
|
82
|
+
`SearchQuery` is a *pydantic* model for building the query-string sent to
|
|
83
|
+
Jaeger’s `/api/traces` endpoint.
|
|
84
|
+
|
|
85
|
+
Parameter logic:
|
|
86
|
+
|
|
87
|
+
* **service** – mandatory, sets the top-level service filter.
|
|
88
|
+
* **operation** – optional; a trace is kept only if *any* of its spans has
|
|
89
|
+
this exact `operationName`.
|
|
90
|
+
* **tags** – dict of key/value filters; *all* pairs must match on a single
|
|
91
|
+
span (logical AND). Example: `{"error": "true"}`.
|
|
92
|
+
* **limit** – applied after all filters and trims the result list.
|
|
93
|
+
|
|
94
|
+
Any other fields are forwarded untouched, so you can experiment with new
|
|
95
|
+
Jaeger parameters without waiting for an SDK update.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Compatibility
|
|
100
|
+
|
|
101
|
+
The implementation targets **Jaeger v1.x** REST endpoints. For clusters
|
|
102
|
+
backed by **OpenSearch** storage the same endpoints apply. Should you
|
|
103
|
+
need API v3 support feel free to open an issue or contribution—thanks!
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Licence
|
|
108
|
+
|
|
109
|
+
This package is released under the **MIT license**.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Jaeger interface for searching and retrieving traces.
|
|
2
|
+
|
|
3
|
+
This sub-package provides a thin synchronous wrapper around the Jaeger
|
|
4
|
+
Query Service HTTP API.
|
|
5
|
+
|
|
6
|
+
Typical usage example::
|
|
7
|
+
|
|
8
|
+
from veris_ai.jaeger_interface import JaegerClient, SearchQuery
|
|
9
|
+
|
|
10
|
+
client = JaegerClient("http://localhost:16686")
|
|
11
|
+
|
|
12
|
+
traces = client.search(
|
|
13
|
+
SearchQuery(service="veris-agent", limit=20)
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
trace = client.get_trace(traces.data[0].traceID)
|
|
17
|
+
|
|
18
|
+
The implementation uses *requests* under the hood and all public functions
|
|
19
|
+
are fully typed using *pydantic* models so that IDEs can provide proper
|
|
20
|
+
autocomplete and type checking.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .client import JaegerClient
|
|
24
|
+
from .models import SearchQuery
|
|
25
|
+
|
|
26
|
+
__all__ = ["JaegerClient", "SearchQuery"]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Synchronous Jaeger Query Service client built on **requests**.
|
|
2
|
+
|
|
3
|
+
This implementation keeps dependencies minimal while providing fully-typed
|
|
4
|
+
*pydantic* models for both **request** and **response** bodies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Self
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from .models import GetTraceResponse, SearchQuery, SearchResponse
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from types import TracebackType
|
|
17
|
+
|
|
18
|
+
__all__ = ["JaegerClient"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JaegerClient: # noqa: D101
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
base_url: str,
|
|
25
|
+
*,
|
|
26
|
+
timeout: float | None = 10.0,
|
|
27
|
+
session: requests.Session | None = None,
|
|
28
|
+
headers: dict[str, str] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Create a new *JaegerClient* instance.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
base_url: Base URL of the Jaeger Query Service (e.g. ``http://localhost:16686``).
|
|
34
|
+
timeout: Request timeout in **seconds** (applied to every call).
|
|
35
|
+
session: Optional pre-configured :class:`requests.Session` to reuse.
|
|
36
|
+
headers: Optional default headers to send with every request.
|
|
37
|
+
"""
|
|
38
|
+
# Normalise to avoid trailing slash duplicates
|
|
39
|
+
self._base_url = base_url.rstrip("/")
|
|
40
|
+
self._timeout = timeout
|
|
41
|
+
self._external_session = session # If provided we won't close it
|
|
42
|
+
self._headers = headers or {}
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------
|
|
45
|
+
# Internal helpers
|
|
46
|
+
# ---------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def _make_session(self) -> tuple[requests.Session, bool]: # noqa: D401
|
|
49
|
+
"""Return a *(session, should_close)* tuple.
|
|
50
|
+
|
|
51
|
+
If an external session was supplied we **must not** close it after the
|
|
52
|
+
request, hence the boolean flag letting callers know whether they are
|
|
53
|
+
responsible for closing the session.
|
|
54
|
+
"""
|
|
55
|
+
if self._external_session is not None:
|
|
56
|
+
return self._external_session, False
|
|
57
|
+
|
|
58
|
+
# Reuse the session opened via the context manager if available
|
|
59
|
+
if hasattr(self, "_session_ctx"):
|
|
60
|
+
return self._session_ctx, False
|
|
61
|
+
|
|
62
|
+
session = requests.Session()
|
|
63
|
+
session.headers.update(self._headers)
|
|
64
|
+
return session, True
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------
|
|
67
|
+
# Public API
|
|
68
|
+
# ---------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def search(self, query: SearchQuery) -> SearchResponse: # noqa: D401
|
|
71
|
+
"""Search traces using the *v1* ``/api/traces`` endpoint.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
query: :class:`~veris_ai.jaeger_interface.models.SearchQuery` instance.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Parsed :class:`~veris_ai.jaeger_interface.models.SearchResponse` model.
|
|
78
|
+
"""
|
|
79
|
+
params = query.to_params()
|
|
80
|
+
session, should_close = self._make_session()
|
|
81
|
+
try:
|
|
82
|
+
url = f"{self._base_url}/api/traces"
|
|
83
|
+
response = session.get(url, params=params, timeout=self._timeout)
|
|
84
|
+
response.raise_for_status()
|
|
85
|
+
data = response.json()
|
|
86
|
+
finally:
|
|
87
|
+
if should_close:
|
|
88
|
+
session.close()
|
|
89
|
+
return SearchResponse.model_validate(data) # type: ignore[arg-type]
|
|
90
|
+
|
|
91
|
+
def get_trace(self, trace_id: str) -> GetTraceResponse: # noqa: D401
|
|
92
|
+
"""Retrieve a single trace by *trace_id*.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
trace_id: The Jaeger trace identifier.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Parsed :class:`~veris_ai.jaeger_interface.models.GetTraceResponse` model.
|
|
99
|
+
"""
|
|
100
|
+
if not trace_id:
|
|
101
|
+
error_msg = "trace_id must be non-empty"
|
|
102
|
+
raise ValueError(error_msg)
|
|
103
|
+
|
|
104
|
+
session, should_close = self._make_session()
|
|
105
|
+
try:
|
|
106
|
+
url = f"{self._base_url}/api/traces/{trace_id}"
|
|
107
|
+
response = session.get(url, timeout=self._timeout)
|
|
108
|
+
response.raise_for_status()
|
|
109
|
+
data = response.json()
|
|
110
|
+
finally:
|
|
111
|
+
if should_close:
|
|
112
|
+
session.close()
|
|
113
|
+
return GetTraceResponse.model_validate(data) # type: ignore[arg-type]
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# Context-manager helpers (optional but convenient)
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def __enter__(self) -> Self:
|
|
120
|
+
"""Enter the context manager."""
|
|
121
|
+
self._session_ctx, self._should_close_ctx = self._make_session()
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def __exit__(
|
|
125
|
+
self,
|
|
126
|
+
exc_type: type[BaseException] | None,
|
|
127
|
+
exc: BaseException | None,
|
|
128
|
+
tb: TracebackType | None,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Exit the context manager."""
|
|
131
|
+
# Only close if we created the session
|
|
132
|
+
if getattr(self, "_should_close_ctx", False):
|
|
133
|
+
self._session_ctx.close()
|