veris-ai 1.1.0__tar.gz → 1.2.0__tar.gz
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-1.1.0 → veris_ai-1.2.0}/PKG-INFO +14 -5
- {veris_ai-1.1.0 → veris_ai-1.2.0}/README.md +13 -4
- {veris_ai-1.1.0 → veris_ai-1.2.0}/pyproject.toml +1 -1
- {veris_ai-1.1.0 → veris_ai-1.2.0}/src/veris_ai/__init__.py +3 -2
- veris_ai-1.2.0/src/veris_ai/jaeger_interface/README.md +137 -0
- veris_ai-1.2.0/src/veris_ai/jaeger_interface/__init__.py +39 -0
- veris_ai-1.2.0/src/veris_ai/jaeger_interface/client.py +233 -0
- veris_ai-1.2.0/src/veris_ai/jaeger_interface/models.py +79 -0
- veris_ai-1.2.0/src/veris_ai/models.py +11 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/src/veris_ai/tool_mock.py +82 -22
- {veris_ai-1.1.0 → veris_ai-1.2.0}/src/veris_ai/utils.py +1 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/test_utils.py +22 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/uv.lock +2 -2
- veris_ai-1.1.0/src/veris_ai/jaeger_interface/README.md +0 -109
- veris_ai-1.1.0/src/veris_ai/jaeger_interface/__init__.py +0 -26
- veris_ai-1.1.0/src/veris_ai/jaeger_interface/client.py +0 -133
- veris_ai-1.1.0/src/veris_ai/jaeger_interface/models.py +0 -153
- {veris_ai-1.1.0 → veris_ai-1.2.0}/.github/workflows/release.yml +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/.github/workflows/test.yml +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/.gitignore +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/CHANGELOG.md +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/CLAUDE.md +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/LICENSE +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/examples/__init__.py +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/examples/import_options.py +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/src/veris_ai/braintrust_tracing.py +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/__init__.py +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/conftest.py +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/fixtures/__init__.py +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/fixtures/simple_app.py +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/fixtures/sse_server.py +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/test_mcp_protocol_server_mocked.py +0 -0
- {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/test_tool_mock.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: veris-ai
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: A Python package for Veris AI tools
|
|
5
5
|
Project-URL: Homepage, https://github.com/veris-ai/veris-python-sdk
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/veris-ai/veris-python-sdk/issues
|
|
@@ -64,7 +64,7 @@ The SDK supports flexible import patterns to minimize dependencies:
|
|
|
64
64
|
|
|
65
65
|
```python
|
|
66
66
|
# These imports only require base dependencies (httpx, pydantic, requests)
|
|
67
|
-
from veris_ai import veris, JaegerClient
|
|
67
|
+
from veris_ai import veris, JaegerClient
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
### Optional Imports (Require Extra Dependencies)
|
|
@@ -434,14 +434,23 @@ This project is licensed under the MIT License - see the LICENSE file for detail
|
|
|
434
434
|
## Jaeger Trace Interface
|
|
435
435
|
|
|
436
436
|
A lightweight, fully-typed wrapper around the Jaeger **Query Service** HTTP API lives under `veris_ai.jaeger_interface`.
|
|
437
|
-
It allows you to **search and retrieve traces** from both Jaeger
|
|
437
|
+
It allows you to **search and retrieve traces** from both Jaeger's default storage back-ends as well as **OpenSearch**, with additional **client-side span filtering** capabilities.
|
|
438
438
|
|
|
439
439
|
```python
|
|
440
|
-
from veris_ai.jaeger_interface import JaegerClient
|
|
440
|
+
from veris_ai.jaeger_interface import JaegerClient
|
|
441
441
|
|
|
442
442
|
client = JaegerClient("http://localhost:16686")
|
|
443
443
|
|
|
444
|
-
|
|
444
|
+
# Search with trace-level filters (server-side)
|
|
445
|
+
traces = client.search(service="veris-agent", limit=2, tags={"error": "true"})
|
|
446
|
+
|
|
447
|
+
# Search with span-level filters (client-side, OR logic)
|
|
448
|
+
filtered = client.search(
|
|
449
|
+
service="veris-agent",
|
|
450
|
+
limit=10,
|
|
451
|
+
span_tags={"http.status_code": 500, "db.error": "timeout"}
|
|
452
|
+
)
|
|
453
|
+
|
|
445
454
|
first_trace = client.get_trace(traces.data[0].traceID)
|
|
446
455
|
```
|
|
447
456
|
|
|
@@ -28,7 +28,7 @@ The SDK supports flexible import patterns to minimize dependencies:
|
|
|
28
28
|
|
|
29
29
|
```python
|
|
30
30
|
# These imports only require base dependencies (httpx, pydantic, requests)
|
|
31
|
-
from veris_ai import veris, JaegerClient
|
|
31
|
+
from veris_ai import veris, JaegerClient
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
### Optional Imports (Require Extra Dependencies)
|
|
@@ -398,14 +398,23 @@ This project is licensed under the MIT License - see the LICENSE file for detail
|
|
|
398
398
|
## Jaeger Trace Interface
|
|
399
399
|
|
|
400
400
|
A lightweight, fully-typed wrapper around the Jaeger **Query Service** HTTP API lives under `veris_ai.jaeger_interface`.
|
|
401
|
-
It allows you to **search and retrieve traces** from both Jaeger
|
|
401
|
+
It allows you to **search and retrieve traces** from both Jaeger's default storage back-ends as well as **OpenSearch**, with additional **client-side span filtering** capabilities.
|
|
402
402
|
|
|
403
403
|
```python
|
|
404
|
-
from veris_ai.jaeger_interface import JaegerClient
|
|
404
|
+
from veris_ai.jaeger_interface import JaegerClient
|
|
405
405
|
|
|
406
406
|
client = JaegerClient("http://localhost:16686")
|
|
407
407
|
|
|
408
|
-
|
|
408
|
+
# Search with trace-level filters (server-side)
|
|
409
|
+
traces = client.search(service="veris-agent", limit=2, tags={"error": "true"})
|
|
410
|
+
|
|
411
|
+
# Search with span-level filters (client-side, OR logic)
|
|
412
|
+
filtered = client.search(
|
|
413
|
+
service="veris-agent",
|
|
414
|
+
limit=10,
|
|
415
|
+
span_tags={"http.status_code": 500, "db.error": "timeout"}
|
|
416
|
+
)
|
|
417
|
+
|
|
409
418
|
first_trace = client.get_trace(traces.data[0].traceID)
|
|
410
419
|
```
|
|
411
420
|
|
|
@@ -5,8 +5,9 @@ from typing import Any
|
|
|
5
5
|
__version__ = "0.1.0"
|
|
6
6
|
|
|
7
7
|
# Import lightweight modules that only use base dependencies
|
|
8
|
-
from .jaeger_interface import JaegerClient, SearchQuery
|
|
9
8
|
from .tool_mock import veris
|
|
9
|
+
from .jaeger_interface import JaegerClient
|
|
10
|
+
from .models import ResponseExpectation
|
|
10
11
|
|
|
11
12
|
# Lazy import for modules with heavy dependencies
|
|
12
13
|
_instrument = None
|
|
@@ -36,6 +37,6 @@ def instrument(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
|
36
37
|
__all__ = [
|
|
37
38
|
"veris",
|
|
38
39
|
"JaegerClient",
|
|
39
|
-
"SearchQuery",
|
|
40
40
|
"instrument",
|
|
41
|
+
"ResponseExpectation"
|
|
41
42
|
]
|
|
@@ -0,0 +1,137 @@
|
|
|
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. It also provides **client-side span filtering**
|
|
7
|
+
capabilities for more granular control over the returned data.
|
|
8
|
+
|
|
9
|
+
> The client relies on `requests` (already included in the SDK's
|
|
10
|
+
> dependencies) and uses *pydantic* for full type-safety.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
`veris-ai` already lists both `requests` and `pydantic` as hard
|
|
17
|
+
requirements, so **no additional dependencies are required**.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install veris-ai
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Quick-start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from veris_ai.jaeger_interface import JaegerClient
|
|
29
|
+
import json
|
|
30
|
+
from veris_ai.jaeger_interface.models import Trace
|
|
31
|
+
# Replace with the URL of your Jaeger Query Service instance
|
|
32
|
+
client = JaegerClient("http://localhost:16686")
|
|
33
|
+
|
|
34
|
+
# --- 1. Search traces --------------------------------------------------
|
|
35
|
+
resp = client.search(
|
|
36
|
+
service="veris-agent",
|
|
37
|
+
limit=10,
|
|
38
|
+
# operation="CustomSpanData",
|
|
39
|
+
tags={"veris.session_id":"088b5aaf-84bd-4768-9a62-5e981222a9f2"},
|
|
40
|
+
span_tags={"bt.metadata.model":"gpt-4.1-2025-04-14"}
|
|
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(service, *, limit=None, tags=None, operation=None, span_tags=None, **kwargs) -> SearchResponse` | Search for traces with optional span-level filtering. |
|
|
78
|
+
| `get_trace(trace_id: str) -> GetTraceResponse` | Fetch a single trace by ID (wrapper around `/api/traces/{id}`). |
|
|
79
|
+
|
|
80
|
+
### `search()` Parameters
|
|
81
|
+
|
|
82
|
+
The `search()` method now uses a flattened parameter structure:
|
|
83
|
+
|
|
84
|
+
| Parameter | Type | Description |
|
|
85
|
+
| --------- | ---- | ----------- |
|
|
86
|
+
| `service` | `str` | Service name to search for. Optional - if not provided, searches across all services. |
|
|
87
|
+
| `limit` | `int` | Maximum number of traces to return. |
|
|
88
|
+
| `tags` | `Dict[str, Any]` | Trace-level tag filters (AND logic). A trace must have a span matching ALL tags. |
|
|
89
|
+
| `operation` | `str` | Filter by operation name. |
|
|
90
|
+
| `span_tags` | `Dict[str, Any]` | Span-level tag filters (OR logic). Returns only spans matching ANY of these tags. |
|
|
91
|
+
| `span_operations` | `List[str]` | Span-level operation name filters (OR logic). Returns only spans matching ANY of these operations. |
|
|
92
|
+
| `**kwargs` | `Any` | Additional parameters passed directly to Jaeger API. |
|
|
93
|
+
|
|
94
|
+
### Filter Logic
|
|
95
|
+
|
|
96
|
+
The interface provides two levels of filtering:
|
|
97
|
+
|
|
98
|
+
1. **Trace-level filtering** (`tags` parameter):
|
|
99
|
+
- Sent directly to Jaeger API
|
|
100
|
+
- Uses AND logic: all tag key-value pairs must match on a single span
|
|
101
|
+
- Efficient server-side filtering
|
|
102
|
+
|
|
103
|
+
2. **Span-level filtering** (`span_tags` parameter):
|
|
104
|
+
- Applied client-side after retrieving traces
|
|
105
|
+
- Uses OR logic: spans matching ANY of the provided tags are included
|
|
106
|
+
- Traces with no matching spans are excluded from results
|
|
107
|
+
- Useful for finding spans with specific characteristics across different traces
|
|
108
|
+
|
|
109
|
+
### Example: Combining Filters
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
# Find traces in service that have errors, then filter to show only
|
|
113
|
+
# spans with specific HTTP status codes or database errors
|
|
114
|
+
traces = client.search(
|
|
115
|
+
service="my-api",
|
|
116
|
+
tags={"error": "true"}, # Trace must contain an error
|
|
117
|
+
span_tags={
|
|
118
|
+
"http.status_code": 500,
|
|
119
|
+
"http.status_code": 503,
|
|
120
|
+
"db.error": "connection_timeout"
|
|
121
|
+
} # Show only spans with these specific errors
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Compatibility
|
|
128
|
+
|
|
129
|
+
The implementation targets **Jaeger v1.x** REST endpoints. For clusters
|
|
130
|
+
backed by **OpenSearch** storage the same endpoints apply. Should you
|
|
131
|
+
need API v3 support feel free to open an issue or contribution—thanks!
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
This package is released under the **MIT license**.
|
|
@@ -0,0 +1,39 @@
|
|
|
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 with client-side span filtering capabilities.
|
|
5
|
+
|
|
6
|
+
Typical usage example::
|
|
7
|
+
|
|
8
|
+
from veris_ai.jaeger_interface import JaegerClient
|
|
9
|
+
|
|
10
|
+
client = JaegerClient("http://localhost:16686")
|
|
11
|
+
|
|
12
|
+
# Search traces with trace-level filters
|
|
13
|
+
traces = client.search(
|
|
14
|
+
service="veris-agent",
|
|
15
|
+
limit=20,
|
|
16
|
+
tags={"error": "true"} # AND logic at trace level
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Search with span-level filtering
|
|
20
|
+
traces_filtered = client.search(
|
|
21
|
+
service="veris-agent",
|
|
22
|
+
limit=20,
|
|
23
|
+
span_tags={
|
|
24
|
+
"http.status_code": 404,
|
|
25
|
+
"db.error": "timeout"
|
|
26
|
+
} # OR logic: spans with either tag are included
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Get a specific trace
|
|
30
|
+
trace = client.get_trace(traces.data[0].traceID)
|
|
31
|
+
|
|
32
|
+
The implementation uses *requests* under the hood and all public functions
|
|
33
|
+
are fully typed using *pydantic* models so that IDEs can provide proper
|
|
34
|
+
autocomplete and type checking.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from .client import JaegerClient as JaegerClient # noqa: F401
|
|
38
|
+
|
|
39
|
+
__all__ = ["JaegerClient"]
|
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
import json
|
|
8
|
+
from typing import Any, Dict, List, Optional, Self
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .models import GetTraceResponse, SearchResponse, Span, Tag, Trace
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = ["JaegerClient"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class JaegerClient: # noqa: D101
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
base_url: str,
|
|
22
|
+
*,
|
|
23
|
+
timeout: float | None = 10.0,
|
|
24
|
+
session: requests.Session | None = None,
|
|
25
|
+
headers: dict[str, str] | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Create a new *JaegerClient* instance.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
base_url: Base URL of the Jaeger Query Service (e.g. ``http://localhost:16686``).
|
|
31
|
+
timeout: Request timeout in **seconds** (applied to every call).
|
|
32
|
+
session: Optional pre-configured :class:`requests.Session` to reuse.
|
|
33
|
+
headers: Optional default headers to send with every request.
|
|
34
|
+
"""
|
|
35
|
+
# Normalise to avoid trailing slash duplicates
|
|
36
|
+
self._base_url = base_url.rstrip("/")
|
|
37
|
+
self._timeout = timeout
|
|
38
|
+
self._external_session = session # If provided we won't close it
|
|
39
|
+
self._headers = headers or {}
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------
|
|
42
|
+
# Internal helpers
|
|
43
|
+
# ---------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def _make_session(self) -> tuple[requests.Session, bool]: # noqa: D401
|
|
46
|
+
"""Return a *(session, should_close)* tuple.
|
|
47
|
+
|
|
48
|
+
If an external session was supplied we **must not** close it after the
|
|
49
|
+
request, hence the boolean flag letting callers know whether they are
|
|
50
|
+
responsible for closing the session.
|
|
51
|
+
"""
|
|
52
|
+
if self._external_session is not None:
|
|
53
|
+
return self._external_session, False
|
|
54
|
+
|
|
55
|
+
# Reuse the session opened via the context manager if available
|
|
56
|
+
if hasattr(self, "_session_ctx"):
|
|
57
|
+
return self._session_ctx, False
|
|
58
|
+
|
|
59
|
+
session = requests.Session()
|
|
60
|
+
session.headers.update(self._headers)
|
|
61
|
+
return session, True
|
|
62
|
+
|
|
63
|
+
def _span_matches_tags(self, span: Span, span_tags: Dict[str, Any]) -> bool:
|
|
64
|
+
"""Check if a span matches any of the provided tags (OR logic)."""
|
|
65
|
+
if not span.tags or not span_tags:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
# Convert span tags to a dict for easier lookup
|
|
69
|
+
span_tag_dict = {tag.key: tag.value for tag in span.tags}
|
|
70
|
+
|
|
71
|
+
# OR logic: return True if ANY tag matches
|
|
72
|
+
for key, value in span_tags.items():
|
|
73
|
+
if span_tag_dict.get(key) == value:
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
def _filter_spans(
|
|
79
|
+
self,
|
|
80
|
+
traces: List[Trace],
|
|
81
|
+
span_tags: Optional[Dict[str, Any]],
|
|
82
|
+
span_operations: Optional[List[str]] = None
|
|
83
|
+
) -> List[Trace]:
|
|
84
|
+
"""
|
|
85
|
+
Filter spans within traces based on span_tags (OR logic) and/or span_operations (OR logic).
|
|
86
|
+
If both are provided, a span must match at least one tag AND at least one operation.
|
|
87
|
+
"""
|
|
88
|
+
if not span_tags and not span_operations:
|
|
89
|
+
return traces
|
|
90
|
+
|
|
91
|
+
filtered_traces = []
|
|
92
|
+
for trace in traces:
|
|
93
|
+
filtered_spans = []
|
|
94
|
+
for span in trace.spans:
|
|
95
|
+
tag_match = True
|
|
96
|
+
op_match = True
|
|
97
|
+
|
|
98
|
+
if span_tags:
|
|
99
|
+
tag_match = self._span_matches_tags(span, span_tags)
|
|
100
|
+
if span_operations:
|
|
101
|
+
op_match = span.operationName in span_operations
|
|
102
|
+
|
|
103
|
+
# If both filters are provided, require both to match (AND logic)
|
|
104
|
+
if tag_match and op_match:
|
|
105
|
+
filtered_spans.append(span)
|
|
106
|
+
|
|
107
|
+
if filtered_spans:
|
|
108
|
+
filtered_trace = Trace(
|
|
109
|
+
traceID=trace.traceID,
|
|
110
|
+
spans=filtered_spans,
|
|
111
|
+
process=trace.process,
|
|
112
|
+
warnings=trace.warnings
|
|
113
|
+
)
|
|
114
|
+
filtered_traces.append(filtered_trace)
|
|
115
|
+
|
|
116
|
+
return filtered_traces
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------
|
|
119
|
+
# Public API
|
|
120
|
+
# ---------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def search(
|
|
123
|
+
self,
|
|
124
|
+
service: Optional[str] = None,
|
|
125
|
+
*,
|
|
126
|
+
limit: Optional[int] = None,
|
|
127
|
+
tags: Optional[Dict[str, Any]] = None,
|
|
128
|
+
operation: Optional[str] = None,
|
|
129
|
+
span_tags: Optional[Dict[str, Any]] = None,
|
|
130
|
+
span_operations: Optional[List[str]] = None,
|
|
131
|
+
**kwargs: Any
|
|
132
|
+
) -> SearchResponse: # noqa: D401
|
|
133
|
+
"""Search traces using the *v1* ``/api/traces`` endpoint with optional span filtering.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
service: Service name to search for. If not provided, searches across all services.
|
|
137
|
+
limit: Maximum number of traces to return.
|
|
138
|
+
tags: Dictionary of tag filters for trace-level filtering (AND-combined).
|
|
139
|
+
operation: Operation name to search for.
|
|
140
|
+
span_tags: Dictionary of tag filters for span-level filtering (OR-combined AND-combined with span_operations).
|
|
141
|
+
Applied client-side after retrieving traces.
|
|
142
|
+
span_operations: List of operation names to search for (OR-combined AND-combined with span_tags).
|
|
143
|
+
**kwargs: Additional parameters to pass to the Jaeger API.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Parsed :class:`~veris_ai.jaeger_interface.models.SearchResponse` model
|
|
147
|
+
with spans filtered according to span_tags if provided.
|
|
148
|
+
"""
|
|
149
|
+
# Build params for the Jaeger API (excluding span_tags)
|
|
150
|
+
params: Dict[str, Any] = {}
|
|
151
|
+
|
|
152
|
+
if service is not None:
|
|
153
|
+
params["service"] = service
|
|
154
|
+
|
|
155
|
+
if limit is not None:
|
|
156
|
+
params["limit"] = limit
|
|
157
|
+
|
|
158
|
+
if operation is not None:
|
|
159
|
+
params["operation"] = operation
|
|
160
|
+
|
|
161
|
+
if tags:
|
|
162
|
+
# Convert tags to JSON string as expected by Jaeger API
|
|
163
|
+
params["tags"] = json.dumps(tags)
|
|
164
|
+
|
|
165
|
+
# Add any additional parameters
|
|
166
|
+
params.update(kwargs)
|
|
167
|
+
|
|
168
|
+
session, should_close = self._make_session()
|
|
169
|
+
try:
|
|
170
|
+
url = f"{self._base_url}/api/traces"
|
|
171
|
+
response = session.get(url, params=params, timeout=self._timeout)
|
|
172
|
+
response.raise_for_status()
|
|
173
|
+
data = response.json()
|
|
174
|
+
finally:
|
|
175
|
+
if should_close:
|
|
176
|
+
session.close()
|
|
177
|
+
|
|
178
|
+
# Parse the response
|
|
179
|
+
search_response = SearchResponse.model_validate(data) # type: ignore[arg-type]
|
|
180
|
+
|
|
181
|
+
# Apply span-level filtering if span_tags is provided
|
|
182
|
+
if span_tags or span_operations and search_response.data and isinstance(search_response.data, list):
|
|
183
|
+
filtered_traces = self._filter_spans(search_response.data, span_tags, span_operations)
|
|
184
|
+
search_response.data = filtered_traces
|
|
185
|
+
# Update the total to reflect filtered results
|
|
186
|
+
if search_response.total is not None:
|
|
187
|
+
search_response.total = len(filtered_traces)
|
|
188
|
+
|
|
189
|
+
return search_response
|
|
190
|
+
|
|
191
|
+
def get_trace(self, trace_id: str) -> GetTraceResponse: # noqa: D401
|
|
192
|
+
"""Retrieve a single trace by *trace_id*.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
trace_id: The Jaeger trace identifier.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Parsed :class:`~veris_ai.jaeger_interface.models.GetTraceResponse` model.
|
|
199
|
+
"""
|
|
200
|
+
if not trace_id:
|
|
201
|
+
error_msg = "trace_id must be non-empty"
|
|
202
|
+
raise ValueError(error_msg)
|
|
203
|
+
|
|
204
|
+
session, should_close = self._make_session()
|
|
205
|
+
try:
|
|
206
|
+
url = f"{self._base_url}/api/traces/{trace_id}"
|
|
207
|
+
response = session.get(url, timeout=self._timeout)
|
|
208
|
+
response.raise_for_status()
|
|
209
|
+
data = response.json()
|
|
210
|
+
finally:
|
|
211
|
+
if should_close:
|
|
212
|
+
session.close()
|
|
213
|
+
return GetTraceResponse.model_validate(data) # type: ignore[arg-type]
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
# Context-manager helpers (optional but convenient)
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def __enter__(self) -> Self:
|
|
220
|
+
"""Enter the context manager."""
|
|
221
|
+
self._session_ctx, self._should_close_ctx = self._make_session()
|
|
222
|
+
return self
|
|
223
|
+
|
|
224
|
+
def __exit__(
|
|
225
|
+
self,
|
|
226
|
+
exc_type: type[BaseException] | None,
|
|
227
|
+
exc: BaseException | None,
|
|
228
|
+
tb: Any | None,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Exit the context manager."""
|
|
231
|
+
# Only close if we created the session
|
|
232
|
+
if getattr(self, "_should_close_ctx", False):
|
|
233
|
+
self._session_ctx.close()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Tag",
|
|
10
|
+
"Process",
|
|
11
|
+
"Span",
|
|
12
|
+
"Trace",
|
|
13
|
+
"SearchResponse",
|
|
14
|
+
"GetTraceResponse",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Tag(BaseModel):
|
|
19
|
+
"""A Jaeger tag key/value pair."""
|
|
20
|
+
|
|
21
|
+
key: str
|
|
22
|
+
value: Any
|
|
23
|
+
type: str | None = None # Jaeger uses an optional *type* field in v1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Process(BaseModel):
|
|
27
|
+
"""Represents the *process* section of a Jaeger trace."""
|
|
28
|
+
|
|
29
|
+
serviceName: str = Field(alias="serviceName") # noqa: N815
|
|
30
|
+
tags: list[Tag] | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Span(BaseModel):
|
|
34
|
+
"""Represents a single Jaeger span."""
|
|
35
|
+
|
|
36
|
+
traceID: str # noqa: N815
|
|
37
|
+
spanID: str # noqa: N815
|
|
38
|
+
operationName: str # noqa: N815
|
|
39
|
+
startTime: int # noqa: N815
|
|
40
|
+
duration: int
|
|
41
|
+
tags: list[Tag] | None = None
|
|
42
|
+
references: list[dict[str, Any]] | None = None
|
|
43
|
+
processID: str | None = None # noqa: N815
|
|
44
|
+
|
|
45
|
+
model_config = ConfigDict(extra="allow")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Trace(BaseModel):
|
|
49
|
+
"""A full Jaeger trace as returned by the Query API."""
|
|
50
|
+
|
|
51
|
+
traceID: str # noqa: N815
|
|
52
|
+
spans: list[Span]
|
|
53
|
+
process: Process | dict[str, Process] | None = None
|
|
54
|
+
warnings: list[str] | None = None
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(extra="allow")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _BaseResponse(BaseModel):
|
|
60
|
+
data: list[Trace] | Trace | None = None
|
|
61
|
+
errors: list[str] | None = None
|
|
62
|
+
|
|
63
|
+
# Allow any additional keys returned by Jaeger so that nothing gets
|
|
64
|
+
# silently dropped if the backend adds new fields we don't know about.
|
|
65
|
+
|
|
66
|
+
model_config = ConfigDict(extra="allow")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SearchResponse(_BaseResponse):
|
|
70
|
+
"""Response model for *search* or *find traces* requests."""
|
|
71
|
+
|
|
72
|
+
total: int | None = None
|
|
73
|
+
limit: int | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class GetTraceResponse(_BaseResponse):
|
|
77
|
+
"""Response model for *get trace by id* requests."""
|
|
78
|
+
|
|
79
|
+
# Same as base but alias for clarity
|
|
@@ -15,6 +15,7 @@ from typing import (
|
|
|
15
15
|
|
|
16
16
|
import httpx
|
|
17
17
|
|
|
18
|
+
from veris_ai.models import ResponseExpectation
|
|
18
19
|
from veris_ai.utils import convert_to_type, extract_json_schema
|
|
19
20
|
|
|
20
21
|
logger = logging.getLogger(__name__)
|
|
@@ -107,18 +108,15 @@ class VerisSDK:
|
|
|
107
108
|
raise ValueError(error_msg)
|
|
108
109
|
# Default timeout of 30 seconds
|
|
109
110
|
timeout = float(os.getenv("VERIS_MOCK_TIMEOUT", "90.0"))
|
|
111
|
+
|
|
112
|
+
# Check if the original function is async
|
|
113
|
+
is_async = inspect.iscoroutinefunction(func)
|
|
110
114
|
|
|
111
|
-
|
|
112
|
-
async def wrapper(
|
|
115
|
+
def create_mock_payload(
|
|
113
116
|
*args: tuple[object, ...],
|
|
114
117
|
**kwargs: dict[str, object],
|
|
115
|
-
) ->
|
|
116
|
-
|
|
117
|
-
env_mode = os.getenv("ENV", "").lower()
|
|
118
|
-
if env_mode != "simulation":
|
|
119
|
-
# If not in simulation mode, execute the original function
|
|
120
|
-
return await func(*args, **kwargs)
|
|
121
|
-
logger.info(f"Simulating function: {func.__name__}")
|
|
118
|
+
) -> tuple[dict[str, Any], Any]:
|
|
119
|
+
"""Create the mock payload - shared logic for both sync and async."""
|
|
122
120
|
sig = inspect.signature(func)
|
|
123
121
|
type_hints = get_type_hints(func)
|
|
124
122
|
|
|
@@ -143,10 +141,18 @@ class VerisSDK:
|
|
|
143
141
|
if expects_response is None and mode == "function":
|
|
144
142
|
expects_response = False
|
|
145
143
|
# Prepare payload
|
|
144
|
+
# Convert expects_response to response_expectation enum
|
|
145
|
+
if expects_response is False:
|
|
146
|
+
response_expectation = ResponseExpectation.NONE
|
|
147
|
+
elif expects_response is True:
|
|
148
|
+
response_expectation = ResponseExpectation.REQUIRED
|
|
149
|
+
else:
|
|
150
|
+
response_expectation = ResponseExpectation.AUTO
|
|
151
|
+
|
|
146
152
|
payload = {
|
|
147
153
|
"session_id": self.session_id,
|
|
148
|
-
"
|
|
149
|
-
"cache_response": cache_response,
|
|
154
|
+
"response_expectation": response_expectation.value,
|
|
155
|
+
"cache_response": bool(cache_response) if cache_response is not None else False,
|
|
150
156
|
"tool_call": {
|
|
151
157
|
"function_name": func.__name__,
|
|
152
158
|
"parameters": params_info,
|
|
@@ -155,6 +161,22 @@ class VerisSDK:
|
|
|
155
161
|
},
|
|
156
162
|
}
|
|
157
163
|
|
|
164
|
+
return payload, return_type_obj
|
|
165
|
+
|
|
166
|
+
@wraps(func)
|
|
167
|
+
async def async_wrapper(
|
|
168
|
+
*args: tuple[object, ...],
|
|
169
|
+
**kwargs: dict[str, object],
|
|
170
|
+
) -> object:
|
|
171
|
+
# Check if we're in simulation mode
|
|
172
|
+
env_mode = os.getenv("ENV", "").lower()
|
|
173
|
+
if env_mode != "simulation":
|
|
174
|
+
# If not in simulation mode, execute the original function
|
|
175
|
+
return await func(*args, **kwargs)
|
|
176
|
+
|
|
177
|
+
logger.info(f"Simulating function: {func.__name__}")
|
|
178
|
+
payload, return_type_obj = create_mock_payload(*args, **kwargs)
|
|
179
|
+
|
|
158
180
|
# Send request to endpoint with timeout
|
|
159
181
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
160
182
|
response = await client.post(endpoint, json=payload)
|
|
@@ -162,36 +184,74 @@ class VerisSDK:
|
|
|
162
184
|
mock_result = response.json()
|
|
163
185
|
logger.info(f"Mock response: {mock_result}")
|
|
164
186
|
|
|
165
|
-
# Convert the mock result to the expected return type
|
|
166
|
-
if mode == "tool":
|
|
167
|
-
return {"content": [{"type": "text", "text": mock_result}]}
|
|
168
|
-
# Parse the mock result if it's a string
|
|
169
|
-
# Extract result field for backwards compatibility
|
|
170
|
-
# Parse the mock result if it's a string
|
|
171
187
|
if isinstance(mock_result, str):
|
|
172
188
|
with suppress(json.JSONDecodeError):
|
|
173
189
|
mock_result = json.loads(mock_result)
|
|
174
190
|
return convert_to_type(mock_result, return_type_obj)
|
|
175
191
|
return convert_to_type(mock_result, return_type_obj)
|
|
176
192
|
|
|
177
|
-
|
|
193
|
+
@wraps(func)
|
|
194
|
+
def sync_wrapper(
|
|
195
|
+
*args: tuple[object, ...],
|
|
196
|
+
**kwargs: dict[str, object],
|
|
197
|
+
) -> object:
|
|
198
|
+
# Check if we're in simulation mode
|
|
199
|
+
env_mode = os.getenv("ENV", "").lower()
|
|
200
|
+
if env_mode != "simulation":
|
|
201
|
+
# If not in simulation mode, execute the original function
|
|
202
|
+
return func(*args, **kwargs)
|
|
203
|
+
|
|
204
|
+
logger.info(f"Simulating function: {func.__name__}")
|
|
205
|
+
payload, return_type_obj = create_mock_payload(*args, **kwargs)
|
|
206
|
+
|
|
207
|
+
# Send request to endpoint with timeout (synchronous)
|
|
208
|
+
with httpx.Client(timeout=timeout) as client:
|
|
209
|
+
response = client.post(endpoint, json=payload)
|
|
210
|
+
response.raise_for_status()
|
|
211
|
+
mock_result = response.json()
|
|
212
|
+
logger.info(f"Mock response: {mock_result}")
|
|
213
|
+
|
|
214
|
+
if isinstance(mock_result, str):
|
|
215
|
+
with suppress(json.JSONDecodeError):
|
|
216
|
+
mock_result = json.loads(mock_result)
|
|
217
|
+
return convert_to_type(mock_result, return_type_obj)
|
|
218
|
+
return convert_to_type(mock_result, return_type_obj)
|
|
219
|
+
|
|
220
|
+
# Return the appropriate wrapper based on whether the function is async
|
|
221
|
+
return async_wrapper if is_async else sync_wrapper
|
|
178
222
|
|
|
179
223
|
return decorator
|
|
180
224
|
|
|
181
225
|
def stub(self, return_value: Any) -> Callable: # noqa: ANN401
|
|
182
|
-
"""Decorator for stubbing
|
|
226
|
+
"""Decorator for stubbing toolw calls."""
|
|
183
227
|
|
|
184
228
|
def decorator(func: Callable) -> Callable:
|
|
229
|
+
# Check if the original function is async
|
|
230
|
+
is_async = inspect.iscoroutinefunction(func)
|
|
231
|
+
|
|
185
232
|
@wraps(func)
|
|
186
|
-
async def
|
|
233
|
+
async def async_wrapper(
|
|
234
|
+
*args: tuple[object, ...],
|
|
235
|
+
**kwargs: dict[str, object],
|
|
236
|
+
) -> object:
|
|
187
237
|
env_mode = os.getenv("ENV", "").lower()
|
|
188
238
|
if env_mode != "simulation":
|
|
189
239
|
# If not in simulation mode, execute the original function
|
|
190
|
-
return
|
|
240
|
+
return func(*args, **kwargs)
|
|
241
|
+
logger.info(f"Simulating function: {func.__name__}")
|
|
242
|
+
return return_value
|
|
243
|
+
|
|
244
|
+
@wraps(func)
|
|
245
|
+
def sync_wrapper(*args: tuple[object, ...], **kwargs: dict[str, object]) -> object:
|
|
246
|
+
env_mode = os.getenv("ENV", "").lower()
|
|
247
|
+
if env_mode != "simulation":
|
|
248
|
+
# If not in simulation mode, execute the original function
|
|
249
|
+
return func(*args, **kwargs)
|
|
191
250
|
logger.info(f"Simulating function: {func.__name__}")
|
|
192
251
|
return return_value
|
|
193
252
|
|
|
194
|
-
|
|
253
|
+
# Return the appropriate wrapper based on whether the function is async
|
|
254
|
+
return async_wrapper if is_async else sync_wrapper
|
|
195
255
|
|
|
196
256
|
return decorator
|
|
197
257
|
|
|
@@ -20,6 +20,7 @@ def convert_to_type(value: object, target_type: type) -> object:
|
|
|
20
20
|
list: _convert_list,
|
|
21
21
|
dict: _convert_dict,
|
|
22
22
|
Union: _convert_union,
|
|
23
|
+
types.UnionType: _convert_union, # Handle Python 3.10+ union syntax (str | int)
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
# Use appropriate converter based on origin
|
|
@@ -108,6 +108,28 @@ class TestConvertToType:
|
|
|
108
108
|
result = convert_to_type(value, list[dict[str, int]])
|
|
109
109
|
assert result == [{"a": 1}, {"b": 2}]
|
|
110
110
|
|
|
111
|
+
def test_convert_to_type_union_python310_syntax(self):
|
|
112
|
+
"""Test conversion with Python 3.10+ union syntax (str | int)."""
|
|
113
|
+
# Test with string value that can be converted to int
|
|
114
|
+
result = convert_to_type("42", str | int)
|
|
115
|
+
assert result == "42" # String type is tried first and succeeds
|
|
116
|
+
assert isinstance(result, str)
|
|
117
|
+
|
|
118
|
+
# Test with int value - will be converted to string (first type in union)
|
|
119
|
+
result = convert_to_type(42, str | int)
|
|
120
|
+
assert result == "42" # Converted to string since str is first in union
|
|
121
|
+
assert isinstance(result, str)
|
|
122
|
+
|
|
123
|
+
# Test with int | str (reversed order) - int comes first
|
|
124
|
+
result = convert_to_type("42", int | str)
|
|
125
|
+
assert result == 42 # Converted to int since int is first
|
|
126
|
+
assert isinstance(result, int)
|
|
127
|
+
|
|
128
|
+
# Test with a value that only works as string
|
|
129
|
+
result = convert_to_type("hello", str | int | float)
|
|
130
|
+
assert result == "hello"
|
|
131
|
+
assert isinstance(result, str)
|
|
132
|
+
|
|
111
133
|
|
|
112
134
|
class TestExtractJsonSchema:
|
|
113
135
|
"""Test the extract_json_schema function."""
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
version = 1
|
|
2
|
-
revision =
|
|
2
|
+
revision = 3
|
|
3
3
|
requires-python = ">=3.11"
|
|
4
4
|
resolution-markers = [
|
|
5
5
|
"python_full_version >= '3.13'",
|
|
@@ -1472,7 +1472,7 @@ wheels = [
|
|
|
1472
1472
|
|
|
1473
1473
|
[[package]]
|
|
1474
1474
|
name = "veris-ai"
|
|
1475
|
-
version = "1.
|
|
1475
|
+
version = "1.1.0"
|
|
1476
1476
|
source = { editable = "." }
|
|
1477
1477
|
dependencies = [
|
|
1478
1478
|
{ name = "httpx" },
|
|
@@ -1,109 +0,0 @@
|
|
|
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**.
|
|
@@ -1,26 +0,0 @@
|
|
|
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"]
|
|
@@ -1,133 +0,0 @@
|
|
|
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()
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
|
-
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
7
|
-
|
|
8
|
-
__all__ = [
|
|
9
|
-
"Tag",
|
|
10
|
-
"Process",
|
|
11
|
-
"Span",
|
|
12
|
-
"Trace",
|
|
13
|
-
"SearchResponse",
|
|
14
|
-
"GetTraceResponse",
|
|
15
|
-
"SearchQuery",
|
|
16
|
-
]
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class Tag(BaseModel):
|
|
20
|
-
"""A Jaeger tag key/value pair."""
|
|
21
|
-
|
|
22
|
-
key: str
|
|
23
|
-
value: Any
|
|
24
|
-
type: str | None = None # Jaeger uses an optional *type* field in v1
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class Process(BaseModel):
|
|
28
|
-
"""Represents the *process* section of a Jaeger trace."""
|
|
29
|
-
|
|
30
|
-
serviceName: str = Field(alias="serviceName") # noqa: N815
|
|
31
|
-
tags: list[Tag] | None = None
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class Span(BaseModel):
|
|
35
|
-
"""Represents a single Jaeger span."""
|
|
36
|
-
|
|
37
|
-
traceID: str # noqa: N815
|
|
38
|
-
spanID: str # noqa: N815
|
|
39
|
-
operationName: str # noqa: N815
|
|
40
|
-
startTime: int # noqa: N815
|
|
41
|
-
duration: int
|
|
42
|
-
tags: list[Tag] | None = None
|
|
43
|
-
references: list[dict[str, Any]] | None = None
|
|
44
|
-
processID: str | None = None # noqa: N815
|
|
45
|
-
|
|
46
|
-
model_config = ConfigDict(extra="allow")
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class Trace(BaseModel):
|
|
50
|
-
"""A full Jaeger trace as returned by the Query API."""
|
|
51
|
-
|
|
52
|
-
traceID: str # noqa: N815
|
|
53
|
-
spans: list[Span]
|
|
54
|
-
process: Process | dict[str, Process] | None = None
|
|
55
|
-
warnings: list[str] | None = None
|
|
56
|
-
|
|
57
|
-
model_config = ConfigDict(extra="allow")
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
class _BaseResponse(BaseModel):
|
|
61
|
-
data: list[Trace] | Trace | None = None
|
|
62
|
-
errors: list[str] | None = None
|
|
63
|
-
|
|
64
|
-
# Allow any additional keys returned by Jaeger so that nothing gets
|
|
65
|
-
# silently dropped if the backend adds new fields we don’t know about.
|
|
66
|
-
|
|
67
|
-
model_config = ConfigDict(extra="allow")
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
class SearchResponse(_BaseResponse):
|
|
71
|
-
"""Response model for *search* or *find traces* requests."""
|
|
72
|
-
|
|
73
|
-
total: int | None = None
|
|
74
|
-
limit: int | None = None
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
class GetTraceResponse(_BaseResponse):
|
|
78
|
-
"""Response model for *get trace by id* requests."""
|
|
79
|
-
|
|
80
|
-
# Same as base but alias for clarity
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
# ---------------------------------------------------------------------------
|
|
84
|
-
# Query models
|
|
85
|
-
# ---------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
class SearchQuery(BaseModel):
|
|
89
|
-
"""Minimal set of query parameters for the `/api/traces` endpoint.
|
|
90
|
-
|
|
91
|
-
Parameter interaction rules:
|
|
92
|
-
|
|
93
|
-
* **service** – global filter; *all* returned traces must belong to this
|
|
94
|
-
service.
|
|
95
|
-
* **operation** – optional secondary filter; returned traces must contain
|
|
96
|
-
*at least one span* whose ``operationName`` equals the provided value.
|
|
97
|
-
* **tags** – dictionary of key‒value pairs; each trace must include a span
|
|
98
|
-
that matches **all** of the pairs (logical AND).
|
|
99
|
-
* **limit** – applied *after* all other filters; truncates the final list
|
|
100
|
-
of traces to the requested maximum.
|
|
101
|
-
|
|
102
|
-
Any additional/unknown parameters are forwarded thanks to
|
|
103
|
-
``extra = "allow"`` – this keeps the model future-proof.
|
|
104
|
-
"""
|
|
105
|
-
|
|
106
|
-
# NOTE: Only the fields that are reliably supported by Jaeger’s REST API and
|
|
107
|
-
# work with the user’s deployment are kept. The model remains *open* to any
|
|
108
|
-
# extra parameters thanks to `extra = "allow"`.
|
|
109
|
-
|
|
110
|
-
service: str = Field(
|
|
111
|
-
...,
|
|
112
|
-
description="Service name to search for. Example: 'veris-agent'",
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
limit: int | None = Field(
|
|
116
|
-
None,
|
|
117
|
-
description="Maximum number of traces to return. Example: 10",
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
tags: dict[str, Any] | None = Field(
|
|
121
|
-
None,
|
|
122
|
-
description=(
|
|
123
|
-
"Dictionary of tag filters (AND-combined). "
|
|
124
|
-
"Example: {'error': 'true', 'bt.metrics.time_to_first_token': '0.813544'}"
|
|
125
|
-
),
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
operation: str | None = Field(
|
|
129
|
-
None,
|
|
130
|
-
description="Operation name to search for. Example: 'process_chat_message'",
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
model_config = ConfigDict(
|
|
134
|
-
extra="allow", # allow additional query params implicitly
|
|
135
|
-
populate_by_name=True,
|
|
136
|
-
str_to_lower=False,
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
@field_validator("tags", mode="before")
|
|
140
|
-
@classmethod
|
|
141
|
-
def _empty_to_none(cls, v: dict[str, Any] | None) -> dict[str, Any] | None: # noqa: D401, ANN102
|
|
142
|
-
return v or None
|
|
143
|
-
|
|
144
|
-
def to_params(self) -> dict[str, Any]: # noqa: D401
|
|
145
|
-
"""Translate the model into a *requests*/*httpx* compatible params dict."""
|
|
146
|
-
# Dump using aliases so ``span_kind`` becomes ``spanKind`` automatically.
|
|
147
|
-
params: dict[str, Any] = self.model_dump(exclude_none=True, by_alias=True)
|
|
148
|
-
|
|
149
|
-
# Convert tags to a JSON string if necessary – this matches what the UI sends.
|
|
150
|
-
if "tags" in params and isinstance(params["tags"], dict):
|
|
151
|
-
params["tags"] = json.dumps(params["tags"])
|
|
152
|
-
|
|
153
|
-
return params
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|