traceAI-exa 0.1.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.
- traceai_exa-0.1.0/PKG-INFO +177 -0
- traceai_exa-0.1.0/README.md +143 -0
- traceai_exa-0.1.0/pyproject.toml +47 -0
- traceai_exa-0.1.0/traceai_exa/__init__.py +141 -0
- traceai_exa-0.1.0/traceai_exa/_wrappers.py +552 -0
- traceai_exa-0.1.0/traceai_exa/package.py +2 -0
- traceai_exa-0.1.0/traceai_exa/py.typed +1 -0
- traceai_exa-0.1.0/traceai_exa/version.py +1 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: traceAI-exa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenTelemetry instrumentation for Exa
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: traceai,futureagi,opentelemetry,tracing,observability,llm,exa
|
|
7
|
+
Author: Future AGI
|
|
8
|
+
Author-email: no-reply@futureagi.com
|
|
9
|
+
Requires-Python: >=3.10,<3.14
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: OpenTelemetry
|
|
12
|
+
Classifier: Framework :: OpenTelemetry :: Instrumentations
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Monitoring
|
|
24
|
+
Requires-Dist: exa-py (>=2.25.0,<3)
|
|
25
|
+
Requires-Dist: fi-instrumentation-otel (>=1.1.0)
|
|
26
|
+
Requires-Dist: wrapt (>=1.0.0)
|
|
27
|
+
Project-URL: Changelog, https://github.com/future-agi/traceAI/blob/dev/python/frameworks/exa/CHANGELOG.md
|
|
28
|
+
Project-URL: Documentation, https://docs.futureagi.com/docs/tracing
|
|
29
|
+
Project-URL: Homepage, https://futureagi.com
|
|
30
|
+
Project-URL: Issues, https://github.com/future-agi/traceAI/issues
|
|
31
|
+
Project-URL: Repository, https://github.com/future-agi/traceAI/tree/dev/python/frameworks/exa
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# traceAI-exa
|
|
35
|
+
|
|
36
|
+
OpenTelemetry instrumentation for the [`exa-py`](https://github.com/exa-labs/exa-py) client.
|
|
37
|
+
|
|
38
|
+
This package wraps Exa client calls; it is not an Exa exporter. Exa does not publish an exporter.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install traceAI-exa
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
It accepts `exa-py>=2.25.0,<3` and is tested with `exa-py` 2.25.0 on Python
|
|
47
|
+
3.10, 3.11, 3.12 and 3.13. The test suite also passes on `exa-py` 2.26.0.
|
|
48
|
+
|
|
49
|
+
Live vendor verification (2026-10-17): a real run against `api.exa.ai` with
|
|
50
|
+
`exa-py` 2.25.0 (`search`, `get_contents`, `answer`, both streams, `AsyncExa.search`
|
|
51
|
+
and an invalid-key 401 recorded as ERROR) exported the expected spans to Future
|
|
52
|
+
AGI, and `FI_HIDE_INPUTS=true` kept the query out of every stored column.
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from exa_py import Exa
|
|
58
|
+
from fi_instrumentation import register
|
|
59
|
+
from fi_instrumentation.fi_types import ProjectType
|
|
60
|
+
from traceai_exa import ExaInstrumentor
|
|
61
|
+
|
|
62
|
+
tracer_provider = register(project_name="exa-search", project_type=ProjectType.OBSERVE)
|
|
63
|
+
ExaInstrumentor().instrument(tracer_provider=tracer_provider)
|
|
64
|
+
|
|
65
|
+
client = Exa(api_key="your-exa-api-key")
|
|
66
|
+
client.search("recent retrieval research", num_results=5)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Instrumented calls
|
|
70
|
+
|
|
71
|
+
`Exa` and `AsyncExa` calls to `search`, `get_contents`, `answer`, and the
|
|
72
|
+
deprecated `search_and_contents` alias (traced as `exa.search`) each produce
|
|
73
|
+
one span. `stream_search` and `stream_answer` produce one span that stays open
|
|
74
|
+
until the stream is fully read, closed, or garbage-collected; the returned
|
|
75
|
+
object is still an instance of the vendor's stream class. A stream still open
|
|
76
|
+
when the interpreter exits is ended as cancelled before `register()`'s exit
|
|
77
|
+
hook shuts the exporter down (when `instrument()` runs after `register()`).
|
|
78
|
+
|
|
79
|
+
`gen_ai.span.kind` is `RETRIEVER` for `search`, `answer` and the two streams,
|
|
80
|
+
and `TOOL` for `get_contents`, which fetches the pages you name rather than
|
|
81
|
+
searching. Each span is a child of the active span
|
|
82
|
+
when there is one, and a root span otherwise. The span is current while
|
|
83
|
+
exa-py sends the request, so a span from an HTTP client instrumentation nests
|
|
84
|
+
under it.
|
|
85
|
+
|
|
86
|
+
## Span attributes
|
|
87
|
+
|
|
88
|
+
| Attribute | Calls | Value |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `gen_ai.retrieval.query`, `input.value` | `search`, `answer`, streams | The query, with the client's Exa API key replaced by `[redacted]`, cut to 1 KB of UTF-8 on a character boundary. |
|
|
91
|
+
| `exa.url_count` | `get_contents` | Number of requested URLs (a single URL, a list of URLs, or a list of results). |
|
|
92
|
+
| `exa.urls` | `get_contents` with `capture_urls=True` | See [get_contents URLs](#get_contents-urls). |
|
|
93
|
+
| `exa.result_count` | successful calls | `results` for `search` and `get_contents`, `citations` for `answer`, and the citations summed over every chunk of a fully read stream. Absent when the count is unknown (an error or a cancellation), never 0 for unknown. |
|
|
94
|
+
| `exa.cancelled` | cancelled calls and streams | `true`; see [Errors and cancellation](#errors-and-cancellation). |
|
|
95
|
+
|
|
96
|
+
The spans never carry response content: no result titles, text, highlights or
|
|
97
|
+
summaries, no answer or stream text, no cost, token count or model name. There
|
|
98
|
+
is no setting that adds them. The Exa API key is never recorded; it is
|
|
99
|
+
replaced by `[redacted]` wherever it appears verbatim in a recorded query, URL
|
|
100
|
+
or error text.
|
|
101
|
+
|
|
102
|
+
### get_contents URLs
|
|
103
|
+
|
|
104
|
+
`get_contents` spans record the number of requested URLs, not the URLs,
|
|
105
|
+
because URLs can carry tokens or personal data. To record the URLs as well,
|
|
106
|
+
opt in when instrumenting:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
ExaInstrumentor().instrument(tracer_provider=tracer_provider, capture_urls=True)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
With `capture_urls=True`, `exa.urls` holds at most the first 20
|
|
113
|
+
requested URLs, each with the Exa API key replaced by `[redacted]` and cut to
|
|
114
|
+
1 KB. Query strings are otherwise kept as given. Hidden inputs (below) turn
|
|
115
|
+
URL capture off.
|
|
116
|
+
|
|
117
|
+
## Trace config and context
|
|
118
|
+
|
|
119
|
+
Spans are created through `fi_instrumentation`'s `FITracer`, as in the other
|
|
120
|
+
traceAI instrumentors. Pass a `TraceConfig` to `instrument()`, or set the
|
|
121
|
+
matching `FI_*` environment variables; without `config`, the environment is
|
|
122
|
+
read when `instrument()` runs.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from fi_instrumentation import TraceConfig
|
|
126
|
+
|
|
127
|
+
ExaInstrumentor().instrument(
|
|
128
|
+
tracer_provider=tracer_provider, config=TraceConfig(hide_inputs=True)
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
With `hide_inputs=True` (or `FI_HIDE_INPUTS=true`), `input.value` is
|
|
133
|
+
`__REDACTED__`, `gen_ai.retrieval.query` and `exa.urls` are not recorded,
|
|
134
|
+
and the query or the requested URLs are replaced by `__REDACTED__` in error
|
|
135
|
+
text. The URL and result counts stay. `pii_redaction` applies to the recorded
|
|
136
|
+
attributes and to the error text described below, and
|
|
137
|
+
`using_session`, `using_user`, `using_metadata` and `using_tags` attributes are
|
|
138
|
+
added to the Exa spans.
|
|
139
|
+
|
|
140
|
+
## Errors and cancellation
|
|
141
|
+
|
|
142
|
+
An error raised by exa-py (for example `ValueError` for an HTTP 4xx/5xx
|
|
143
|
+
response) sets the span status to ERROR and records an exception event. The
|
|
144
|
+
exception is re-raised unchanged.
|
|
145
|
+
|
|
146
|
+
exa-py puts the server's response body in its error message, and a body can
|
|
147
|
+
repeat the request. On the span, the status description
|
|
148
|
+
(`<ExceptionType>: <message>`) and the event's `exception.message` therefore
|
|
149
|
+
have the Exa key replaced by `[redacted]` (and, with hidden inputs, the query
|
|
150
|
+
or the requested URLs by `__REDACTED__`; with `pii_redaction`, PII by tokens such
|
|
151
|
+
as `<EMAIL_ADDRESS>`) and the message cut to 1 KB of UTF-8.
|
|
152
|
+
If the exception's own `str()` fails, the span records only its type. The event carries
|
|
153
|
+
`exception.type` and `exception.message` only: no `exception.stacktrace` is
|
|
154
|
+
exported, because frames hold local file paths, user names and host names.
|
|
155
|
+
Chained exceptions' messages are not copied. Only verbatim
|
|
156
|
+
copies are found: an escaped, normalised or truncated echo of the key, query or
|
|
157
|
+
URLs (for example a JSON-escaped `\/` or a percent-encoded URL) is not.
|
|
158
|
+
|
|
159
|
+
Closing a stream before its last chunk (`close()` or `aclose()`), dropping it
|
|
160
|
+
mid-iteration, or cancelling an `AsyncExa` call or stream ends the span with
|
|
161
|
+
status ERROR, description `cancelled`, and `exa.cancelled` = `true`.
|
|
162
|
+
|
|
163
|
+
A traced `AsyncExa` stream always has `aclose()`, which ends the span and closes
|
|
164
|
+
the HTTP response. exa-py's own async streams have `aclose()` from 2.26.0 only;
|
|
165
|
+
2.25.0's have none. On both versions their `close()` raises `RuntimeError`
|
|
166
|
+
(traced or not; the span is ended first). Code that must also run on exa-py
|
|
167
|
+
2.25.0 without this instrumentor can call `aclose()` only when it exists, for
|
|
168
|
+
example `aclose = getattr(stream, "aclose", None)`.
|
|
169
|
+
|
|
170
|
+
A missing API key raises `ValueError` in `Exa()` or `AsyncExa()` itself,
|
|
171
|
+
before any traced method runs, so no span is recorded for it.
|
|
172
|
+
|
|
173
|
+
`find_similar` and `find_similar_and_contents` are not traced.
|
|
174
|
+
|
|
175
|
+
Do not use this package together with another instrumentor that wraps the same
|
|
176
|
+
Exa client methods, or duplicate spans may result.
|
|
177
|
+
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# traceAI-exa
|
|
2
|
+
|
|
3
|
+
OpenTelemetry instrumentation for the [`exa-py`](https://github.com/exa-labs/exa-py) client.
|
|
4
|
+
|
|
5
|
+
This package wraps Exa client calls; it is not an Exa exporter. Exa does not publish an exporter.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install traceAI-exa
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
It accepts `exa-py>=2.25.0,<3` and is tested with `exa-py` 2.25.0 on Python
|
|
14
|
+
3.10, 3.11, 3.12 and 3.13. The test suite also passes on `exa-py` 2.26.0.
|
|
15
|
+
|
|
16
|
+
Live vendor verification (2026-10-17): a real run against `api.exa.ai` with
|
|
17
|
+
`exa-py` 2.25.0 (`search`, `get_contents`, `answer`, both streams, `AsyncExa.search`
|
|
18
|
+
and an invalid-key 401 recorded as ERROR) exported the expected spans to Future
|
|
19
|
+
AGI, and `FI_HIDE_INPUTS=true` kept the query out of every stored column.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from exa_py import Exa
|
|
25
|
+
from fi_instrumentation import register
|
|
26
|
+
from fi_instrumentation.fi_types import ProjectType
|
|
27
|
+
from traceai_exa import ExaInstrumentor
|
|
28
|
+
|
|
29
|
+
tracer_provider = register(project_name="exa-search", project_type=ProjectType.OBSERVE)
|
|
30
|
+
ExaInstrumentor().instrument(tracer_provider=tracer_provider)
|
|
31
|
+
|
|
32
|
+
client = Exa(api_key="your-exa-api-key")
|
|
33
|
+
client.search("recent retrieval research", num_results=5)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Instrumented calls
|
|
37
|
+
|
|
38
|
+
`Exa` and `AsyncExa` calls to `search`, `get_contents`, `answer`, and the
|
|
39
|
+
deprecated `search_and_contents` alias (traced as `exa.search`) each produce
|
|
40
|
+
one span. `stream_search` and `stream_answer` produce one span that stays open
|
|
41
|
+
until the stream is fully read, closed, or garbage-collected; the returned
|
|
42
|
+
object is still an instance of the vendor's stream class. A stream still open
|
|
43
|
+
when the interpreter exits is ended as cancelled before `register()`'s exit
|
|
44
|
+
hook shuts the exporter down (when `instrument()` runs after `register()`).
|
|
45
|
+
|
|
46
|
+
`gen_ai.span.kind` is `RETRIEVER` for `search`, `answer` and the two streams,
|
|
47
|
+
and `TOOL` for `get_contents`, which fetches the pages you name rather than
|
|
48
|
+
searching. Each span is a child of the active span
|
|
49
|
+
when there is one, and a root span otherwise. The span is current while
|
|
50
|
+
exa-py sends the request, so a span from an HTTP client instrumentation nests
|
|
51
|
+
under it.
|
|
52
|
+
|
|
53
|
+
## Span attributes
|
|
54
|
+
|
|
55
|
+
| Attribute | Calls | Value |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `gen_ai.retrieval.query`, `input.value` | `search`, `answer`, streams | The query, with the client's Exa API key replaced by `[redacted]`, cut to 1 KB of UTF-8 on a character boundary. |
|
|
58
|
+
| `exa.url_count` | `get_contents` | Number of requested URLs (a single URL, a list of URLs, or a list of results). |
|
|
59
|
+
| `exa.urls` | `get_contents` with `capture_urls=True` | See [get_contents URLs](#get_contents-urls). |
|
|
60
|
+
| `exa.result_count` | successful calls | `results` for `search` and `get_contents`, `citations` for `answer`, and the citations summed over every chunk of a fully read stream. Absent when the count is unknown (an error or a cancellation), never 0 for unknown. |
|
|
61
|
+
| `exa.cancelled` | cancelled calls and streams | `true`; see [Errors and cancellation](#errors-and-cancellation). |
|
|
62
|
+
|
|
63
|
+
The spans never carry response content: no result titles, text, highlights or
|
|
64
|
+
summaries, no answer or stream text, no cost, token count or model name. There
|
|
65
|
+
is no setting that adds them. The Exa API key is never recorded; it is
|
|
66
|
+
replaced by `[redacted]` wherever it appears verbatim in a recorded query, URL
|
|
67
|
+
or error text.
|
|
68
|
+
|
|
69
|
+
### get_contents URLs
|
|
70
|
+
|
|
71
|
+
`get_contents` spans record the number of requested URLs, not the URLs,
|
|
72
|
+
because URLs can carry tokens or personal data. To record the URLs as well,
|
|
73
|
+
opt in when instrumenting:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
ExaInstrumentor().instrument(tracer_provider=tracer_provider, capture_urls=True)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
With `capture_urls=True`, `exa.urls` holds at most the first 20
|
|
80
|
+
requested URLs, each with the Exa API key replaced by `[redacted]` and cut to
|
|
81
|
+
1 KB. Query strings are otherwise kept as given. Hidden inputs (below) turn
|
|
82
|
+
URL capture off.
|
|
83
|
+
|
|
84
|
+
## Trace config and context
|
|
85
|
+
|
|
86
|
+
Spans are created through `fi_instrumentation`'s `FITracer`, as in the other
|
|
87
|
+
traceAI instrumentors. Pass a `TraceConfig` to `instrument()`, or set the
|
|
88
|
+
matching `FI_*` environment variables; without `config`, the environment is
|
|
89
|
+
read when `instrument()` runs.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from fi_instrumentation import TraceConfig
|
|
93
|
+
|
|
94
|
+
ExaInstrumentor().instrument(
|
|
95
|
+
tracer_provider=tracer_provider, config=TraceConfig(hide_inputs=True)
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
With `hide_inputs=True` (or `FI_HIDE_INPUTS=true`), `input.value` is
|
|
100
|
+
`__REDACTED__`, `gen_ai.retrieval.query` and `exa.urls` are not recorded,
|
|
101
|
+
and the query or the requested URLs are replaced by `__REDACTED__` in error
|
|
102
|
+
text. The URL and result counts stay. `pii_redaction` applies to the recorded
|
|
103
|
+
attributes and to the error text described below, and
|
|
104
|
+
`using_session`, `using_user`, `using_metadata` and `using_tags` attributes are
|
|
105
|
+
added to the Exa spans.
|
|
106
|
+
|
|
107
|
+
## Errors and cancellation
|
|
108
|
+
|
|
109
|
+
An error raised by exa-py (for example `ValueError` for an HTTP 4xx/5xx
|
|
110
|
+
response) sets the span status to ERROR and records an exception event. The
|
|
111
|
+
exception is re-raised unchanged.
|
|
112
|
+
|
|
113
|
+
exa-py puts the server's response body in its error message, and a body can
|
|
114
|
+
repeat the request. On the span, the status description
|
|
115
|
+
(`<ExceptionType>: <message>`) and the event's `exception.message` therefore
|
|
116
|
+
have the Exa key replaced by `[redacted]` (and, with hidden inputs, the query
|
|
117
|
+
or the requested URLs by `__REDACTED__`; with `pii_redaction`, PII by tokens such
|
|
118
|
+
as `<EMAIL_ADDRESS>`) and the message cut to 1 KB of UTF-8.
|
|
119
|
+
If the exception's own `str()` fails, the span records only its type. The event carries
|
|
120
|
+
`exception.type` and `exception.message` only: no `exception.stacktrace` is
|
|
121
|
+
exported, because frames hold local file paths, user names and host names.
|
|
122
|
+
Chained exceptions' messages are not copied. Only verbatim
|
|
123
|
+
copies are found: an escaped, normalised or truncated echo of the key, query or
|
|
124
|
+
URLs (for example a JSON-escaped `\/` or a percent-encoded URL) is not.
|
|
125
|
+
|
|
126
|
+
Closing a stream before its last chunk (`close()` or `aclose()`), dropping it
|
|
127
|
+
mid-iteration, or cancelling an `AsyncExa` call or stream ends the span with
|
|
128
|
+
status ERROR, description `cancelled`, and `exa.cancelled` = `true`.
|
|
129
|
+
|
|
130
|
+
A traced `AsyncExa` stream always has `aclose()`, which ends the span and closes
|
|
131
|
+
the HTTP response. exa-py's own async streams have `aclose()` from 2.26.0 only;
|
|
132
|
+
2.25.0's have none. On both versions their `close()` raises `RuntimeError`
|
|
133
|
+
(traced or not; the span is ended first). Code that must also run on exa-py
|
|
134
|
+
2.25.0 without this instrumentor can call `aclose()` only when it exists, for
|
|
135
|
+
example `aclose = getattr(stream, "aclose", None)`.
|
|
136
|
+
|
|
137
|
+
A missing API key raises `ValueError` in `Exa()` or `AsyncExa()` itself,
|
|
138
|
+
before any traced method runs, so no span is recorded for it.
|
|
139
|
+
|
|
140
|
+
`find_similar` and `find_similar_and_contents` are not traced.
|
|
141
|
+
|
|
142
|
+
Do not use this package together with another instrumentor that wraps the same
|
|
143
|
+
Exa client methods, or duplicate spans may result.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "traceAI-exa"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "OpenTelemetry instrumentation for Exa"
|
|
5
|
+
authors = ["Future AGI <no-reply@futureagi.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
packages = [
|
|
8
|
+
{ include = "traceai_exa" }
|
|
9
|
+
]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
14
|
+
"License :: OSI Approved :: Apache Software License",
|
|
15
|
+
"Programming Language :: Python",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Framework :: OpenTelemetry",
|
|
23
|
+
"Framework :: OpenTelemetry :: Instrumentations",
|
|
24
|
+
"Topic :: System :: Monitoring",
|
|
25
|
+
]
|
|
26
|
+
license = "Apache-2.0"
|
|
27
|
+
keywords = ["traceai", "futureagi", "opentelemetry", "tracing", "observability", "llm", "exa"]
|
|
28
|
+
|
|
29
|
+
[tool.poetry.dependencies]
|
|
30
|
+
python = ">=3.10,<3.14"
|
|
31
|
+
fi-instrumentation-otel = ">=1.1.0"
|
|
32
|
+
exa-py = ">=2.25.0,<3"
|
|
33
|
+
wrapt = ">=1.0.0"
|
|
34
|
+
|
|
35
|
+
[tool.poetry.group.dev.dependencies]
|
|
36
|
+
pytest = ">=7.0.0"
|
|
37
|
+
pytest-asyncio = ">=0.21.0"
|
|
38
|
+
|
|
39
|
+
[tool.poetry.urls]
|
|
40
|
+
Homepage = "https://futureagi.com"
|
|
41
|
+
Documentation = "https://docs.futureagi.com/docs/tracing"
|
|
42
|
+
Repository = "https://github.com/future-agi/traceAI/tree/dev/python/frameworks/exa"
|
|
43
|
+
Changelog = "https://github.com/future-agi/traceAI/blob/dev/python/frameworks/exa/CHANGELOG.md"
|
|
44
|
+
Issues = "https://github.com/future-agi/traceAI/issues"
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["poetry-core"]
|
|
47
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""OpenTelemetry instrumentation for the Exa client."""
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import logging
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
from typing import Any, Collection, Dict, Tuple
|
|
7
|
+
|
|
8
|
+
from fi_instrumentation import FITracer, TraceConfig
|
|
9
|
+
from opentelemetry import trace as trace_api
|
|
10
|
+
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
|
|
11
|
+
from wrapt import wrap_function_wrapper
|
|
12
|
+
|
|
13
|
+
from traceai_exa._wrappers import (
|
|
14
|
+
AsyncOperationWrapper,
|
|
15
|
+
AsyncStreamWrapper,
|
|
16
|
+
OperationWrapper,
|
|
17
|
+
StreamWrapper,
|
|
18
|
+
end_open_streams,
|
|
19
|
+
)
|
|
20
|
+
from traceai_exa.package import _instruments
|
|
21
|
+
from traceai_exa.version import __version__
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
logger.addHandler(logging.NullHandler())
|
|
25
|
+
|
|
26
|
+
_MODULE = "exa_py.api"
|
|
27
|
+
_OPERATIONS = {
|
|
28
|
+
"search": "exa.search",
|
|
29
|
+
"get_contents": "exa.get_contents",
|
|
30
|
+
"answer": "exa.answer",
|
|
31
|
+
# The SDK keeps this deprecated alias for backwards compatibility. It is a
|
|
32
|
+
# search operation, so it deliberately shares the search span name.
|
|
33
|
+
"search_and_contents": "exa.search",
|
|
34
|
+
}
|
|
35
|
+
_STREAM_OPERATIONS = {
|
|
36
|
+
"stream_search": "exa.search",
|
|
37
|
+
"stream_answer": "exa.answer",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ExaInstrumentor(BaseInstrumentor):
|
|
42
|
+
"""Instrument Exa's search, contents, answer, and streaming APIs.
|
|
43
|
+
|
|
44
|
+
``instrument()`` accepts ``tracer_provider``, ``capture_urls`` and
|
|
45
|
+
``config`` (a ``fi_instrumentation.TraceConfig``; by default one built
|
|
46
|
+
from the ``FI_*`` environment variables, such as ``FI_HIDE_INPUTS``).
|
|
47
|
+
Spans come from an ``FITracer``, so the config's masking and PII
|
|
48
|
+
redaction apply, and ``using_session`` / ``using_user`` /
|
|
49
|
+
``using_metadata`` attributes reach the Exa spans.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
__slots__ = ("_original_methods",)
|
|
53
|
+
|
|
54
|
+
def instrumentation_dependencies(self) -> Collection[str]:
|
|
55
|
+
return _instruments
|
|
56
|
+
|
|
57
|
+
def _instrument(self, **kwargs: Any) -> None:
|
|
58
|
+
config = kwargs.get("config")
|
|
59
|
+
if config is None:
|
|
60
|
+
config = TraceConfig()
|
|
61
|
+
elif not isinstance(config, TraceConfig):
|
|
62
|
+
raise TypeError(
|
|
63
|
+
"config must be a fi_instrumentation.TraceConfig, got {0}".format(
|
|
64
|
+
type(config).__name__
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
tracer_provider = kwargs.get("tracer_provider") or trace_api.get_tracer_provider()
|
|
68
|
+
tracer = FITracer(
|
|
69
|
+
trace_api.get_tracer(__name__, __version__, tracer_provider), config=config
|
|
70
|
+
)
|
|
71
|
+
# Off by default: get_contents records only a URL count. With
|
|
72
|
+
# capture_urls=True it also records up to 20 requested URLs, each
|
|
73
|
+
# with the Exa key redacted and capped at 1 KB. Hidden inputs win.
|
|
74
|
+
capture_urls = bool(kwargs.get("capture_urls", False))
|
|
75
|
+
hide_inputs = bool(config.hide_inputs)
|
|
76
|
+
pii = bool(config.pii_redaction)
|
|
77
|
+
api_module = import_module(_MODULE)
|
|
78
|
+
self._original_methods: Dict[Tuple[str, str], Any] = {}
|
|
79
|
+
|
|
80
|
+
for client_name in ("Exa", "AsyncExa"):
|
|
81
|
+
is_async = client_name == "AsyncExa"
|
|
82
|
+
for method_name, span_name in _OPERATIONS.items():
|
|
83
|
+
options = {
|
|
84
|
+
"contents": method_name == "get_contents",
|
|
85
|
+
"capture_urls": capture_urls,
|
|
86
|
+
"hide_inputs": hide_inputs,
|
|
87
|
+
"pii": pii,
|
|
88
|
+
}
|
|
89
|
+
wrapper = (
|
|
90
|
+
AsyncOperationWrapper(tracer, span_name, **options)
|
|
91
|
+
if is_async
|
|
92
|
+
else OperationWrapper(tracer, span_name, **options)
|
|
93
|
+
)
|
|
94
|
+
self._wrap_method(api_module, client_name, method_name, wrapper)
|
|
95
|
+
|
|
96
|
+
for method_name, span_name in _STREAM_OPERATIONS.items():
|
|
97
|
+
wrapper = (
|
|
98
|
+
AsyncStreamWrapper(tracer, span_name, hide_inputs=hide_inputs, pii=pii)
|
|
99
|
+
if is_async
|
|
100
|
+
else StreamWrapper(tracer, span_name, hide_inputs=hide_inputs, pii=pii)
|
|
101
|
+
)
|
|
102
|
+
self._wrap_method(api_module, client_name, method_name, wrapper)
|
|
103
|
+
|
|
104
|
+
# register() adds an atexit hook that shuts the provider down. atexit
|
|
105
|
+
# runs hooks last-in first-out, so (re-)registering here makes open
|
|
106
|
+
# stream spans end before that shutdown, not after it.
|
|
107
|
+
atexit.unregister(end_open_streams)
|
|
108
|
+
atexit.register(end_open_streams)
|
|
109
|
+
logger.debug("Exa instrumentation enabled")
|
|
110
|
+
|
|
111
|
+
def _wrap_method(
|
|
112
|
+
self,
|
|
113
|
+
api_module: Any,
|
|
114
|
+
client_name: str,
|
|
115
|
+
method_name: str,
|
|
116
|
+
wrapper: Any,
|
|
117
|
+
) -> None:
|
|
118
|
+
client = getattr(api_module, client_name)
|
|
119
|
+
# Only a method defined on this class itself: AsyncExa subclasses Exa,
|
|
120
|
+
# and wrapping an inherited method would wrap Exa's (already wrapped)
|
|
121
|
+
# function a second time and leave it wrapped after uninstrument().
|
|
122
|
+
original = vars(client).get(method_name)
|
|
123
|
+
if original is None:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
self._original_methods[(client_name, method_name)] = original
|
|
127
|
+
wrap_function_wrapper(
|
|
128
|
+
api_module,
|
|
129
|
+
"{0}.{1}".format(client_name, method_name),
|
|
130
|
+
wrapper,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _uninstrument(self, **kwargs: Any) -> None:
|
|
134
|
+
api_module = import_module(_MODULE)
|
|
135
|
+
for (client_name, method_name), original in self._original_methods.items():
|
|
136
|
+
setattr(getattr(api_module, client_name), method_name, original)
|
|
137
|
+
self._original_methods.clear()
|
|
138
|
+
logger.debug("Exa instrumentation disabled")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
__all__ = ["ExaInstrumentor", "__version__"]
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""wrapt wrappers for the supported Exa client methods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import weakref
|
|
8
|
+
from typing import Any, Callable, Mapping, Optional, Sequence, Tuple
|
|
9
|
+
|
|
10
|
+
import wrapt
|
|
11
|
+
from fi_instrumentation import REDACTED_VALUE
|
|
12
|
+
from fi_instrumentation.instrumentation.pii_redaction import redact_pii_in_string
|
|
13
|
+
from opentelemetry import trace as trace_api
|
|
14
|
+
from opentelemetry.trace import Span, Status, StatusCode, Tracer
|
|
15
|
+
|
|
16
|
+
_SPAN_KIND = "gen_ai.span.kind"
|
|
17
|
+
_INPUT_VALUE = "input.value"
|
|
18
|
+
_RETRIEVAL_QUERY = "gen_ai.retrieval.query"
|
|
19
|
+
_RESULT_COUNT = "exa.result_count"
|
|
20
|
+
_URL_COUNT = "exa.url_count"
|
|
21
|
+
_URLS = "exa.urls"
|
|
22
|
+
_CANCELLED = "exa.cancelled"
|
|
23
|
+
_RETRIEVER = "RETRIEVER"
|
|
24
|
+
# get_contents fetches given URLs; it is not a search.
|
|
25
|
+
_TOOL = "TOOL"
|
|
26
|
+
_REDACTED = "[redacted]"
|
|
27
|
+
_MAX_QUERY_BYTES = 1024
|
|
28
|
+
_MAX_CAPTURED_URLS = 20
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _redact(value: str, api_key: Optional[str]) -> str:
|
|
32
|
+
return value.replace(api_key, _REDACTED) if api_key else value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _Scrub:
|
|
36
|
+
"""What must not leave the process in error text for one call.
|
|
37
|
+
|
|
38
|
+
``api_key`` is replaced by ``[redacted]``; with hidden inputs, each
|
|
39
|
+
request input (the query, or get_contents' URLs; already key-redacted)
|
|
40
|
+
is replaced by ``__REDACTED__``, longest first; with ``pii_redaction``,
|
|
41
|
+
PII is replaced last. Only verbatim occurrences are found.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
__slots__ = ("api_key", "hidden", "pii")
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self, api_key: Optional[str], hidden: Sequence[str] = (), pii: bool = False
|
|
48
|
+
) -> None:
|
|
49
|
+
self.api_key = api_key
|
|
50
|
+
self.hidden = tuple(sorted({value for value in hidden if value}, key=len, reverse=True))
|
|
51
|
+
self.pii = pii
|
|
52
|
+
|
|
53
|
+
def __call__(self, text: str) -> str:
|
|
54
|
+
text = _redact(text, self.api_key)
|
|
55
|
+
for value in self.hidden:
|
|
56
|
+
text = text.replace(value, REDACTED_VALUE)
|
|
57
|
+
if self.pii:
|
|
58
|
+
text = redact_pii_in_string(text)
|
|
59
|
+
return text
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _query(args: Sequence[Any], kwargs: Mapping[str, Any]) -> Optional[str]:
|
|
63
|
+
"""Return the search or answer query argument, never client configuration."""
|
|
64
|
+
value = kwargs.get("query")
|
|
65
|
+
if value is None and args:
|
|
66
|
+
value = args[0]
|
|
67
|
+
return None if value is None else str(value)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _requested_urls(args: Sequence[Any], kwargs: Mapping[str, Any]) -> Optional[list]:
|
|
71
|
+
"""Return get_contents' requested URLs, or None when the shape is unknown.
|
|
72
|
+
|
|
73
|
+
exa-py accepts one URL, a list of URLs, or a list of Result objects. Only a
|
|
74
|
+
Result's ``url`` is read, so text and highlights cannot reach a span.
|
|
75
|
+
"""
|
|
76
|
+
value = kwargs["urls"] if "urls" in kwargs else (args[0] if args else None)
|
|
77
|
+
if isinstance(value, str):
|
|
78
|
+
return [value]
|
|
79
|
+
if isinstance(value, (list, tuple)):
|
|
80
|
+
return [item if isinstance(item, str) else getattr(item, "url", None) for item in value]
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _request_attributes(
|
|
85
|
+
instance: Any,
|
|
86
|
+
args: Sequence[Any],
|
|
87
|
+
kwargs: Mapping[str, Any],
|
|
88
|
+
contents: bool,
|
|
89
|
+
capture_urls: bool,
|
|
90
|
+
hide_inputs: bool = False,
|
|
91
|
+
pii: bool = False,
|
|
92
|
+
) -> Tuple[dict, _Scrub]:
|
|
93
|
+
attributes: dict = {_SPAN_KIND: _TOOL if contents else _RETRIEVER}
|
|
94
|
+
api_key = _api_key(instance)
|
|
95
|
+
if contents:
|
|
96
|
+
# get_contents: a URL count by default. URLs can carry tokens or
|
|
97
|
+
# personal data, so they are recorded only with capture_urls=True,
|
|
98
|
+
# and never when inputs are hidden.
|
|
99
|
+
urls = _requested_urls(args, kwargs)
|
|
100
|
+
if urls is None:
|
|
101
|
+
return attributes, _Scrub(api_key, pii=pii)
|
|
102
|
+
attributes[_URL_COUNT] = len(urls)
|
|
103
|
+
if hide_inputs:
|
|
104
|
+
# An error body can echo the URLs; hidden inputs keep them out
|
|
105
|
+
# of error text as well as attributes.
|
|
106
|
+
hidden = [_redact(url, api_key) for url in urls if isinstance(url, str)]
|
|
107
|
+
return attributes, _Scrub(api_key, hidden, pii)
|
|
108
|
+
if capture_urls:
|
|
109
|
+
attributes[_URLS] = [
|
|
110
|
+
_cap(_redact(url, api_key))
|
|
111
|
+
for url in urls[:_MAX_CAPTURED_URLS]
|
|
112
|
+
if isinstance(url, str)
|
|
113
|
+
]
|
|
114
|
+
return attributes, _Scrub(api_key, pii=pii)
|
|
115
|
+
|
|
116
|
+
query = _query(args, kwargs)
|
|
117
|
+
if query is None:
|
|
118
|
+
return attributes, _Scrub(api_key, pii=pii)
|
|
119
|
+
if hide_inputs:
|
|
120
|
+
# FITracer masks input.value too; gen_ai.retrieval.query is not one
|
|
121
|
+
# of its keys, so it is left out here.
|
|
122
|
+
attributes[_INPUT_VALUE] = REDACTED_VALUE
|
|
123
|
+
return attributes, _Scrub(api_key, [_redact(query, api_key)], pii)
|
|
124
|
+
query = _cap(_redact(query, api_key))
|
|
125
|
+
attributes[_RETRIEVAL_QUERY] = query
|
|
126
|
+
# The backend input panel reads input.value; keep both keys.
|
|
127
|
+
attributes[_INPUT_VALUE] = query
|
|
128
|
+
return attributes, _Scrub(api_key, pii=pii)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _cap(value: str, limit: int = _MAX_QUERY_BYTES) -> str:
|
|
132
|
+
"""Return the longest whole-character prefix of at most ``limit`` UTF-8 bytes."""
|
|
133
|
+
# A cut inside a multi-byte character leaves an invalid tail; "ignore"
|
|
134
|
+
# drops it. "replace" keeps a lone surrogate from raising in user code.
|
|
135
|
+
return value.encode("utf-8", "replace")[:limit].decode("utf-8", "ignore")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _api_key(instance: Any) -> Optional[str]:
|
|
139
|
+
"""Return the client's Exa key so it can be removed from span values.
|
|
140
|
+
|
|
141
|
+
exa-py keeps the key only in ``headers["x-api-key"]``; ``api_key`` is a
|
|
142
|
+
fallback for clients that expose it as an attribute.
|
|
143
|
+
"""
|
|
144
|
+
key = None
|
|
145
|
+
headers = getattr(instance, "headers", None)
|
|
146
|
+
if isinstance(headers, Mapping):
|
|
147
|
+
key = headers.get("x-api-key")
|
|
148
|
+
if not key:
|
|
149
|
+
key = getattr(instance, "api_key", None)
|
|
150
|
+
return key if isinstance(key, str) and key else None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _document_count(result: Any) -> Optional[int]:
|
|
154
|
+
"""Count returned documents without inspecting their content.
|
|
155
|
+
|
|
156
|
+
SearchResponse (search, get_contents) has ``results``; AnswerResponse has
|
|
157
|
+
``citations``. Anything else is an unknown count: None, never 0.
|
|
158
|
+
"""
|
|
159
|
+
for name in ("results", "citations"):
|
|
160
|
+
documents = getattr(result, name, None)
|
|
161
|
+
if documents is not None:
|
|
162
|
+
try:
|
|
163
|
+
return len(documents)
|
|
164
|
+
except TypeError:
|
|
165
|
+
return None
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _chunk_citations(chunk: Any) -> int:
|
|
170
|
+
"""Citations carried by one StreamChunk (stream_search / stream_answer)."""
|
|
171
|
+
try:
|
|
172
|
+
return len(getattr(chunk, "citations", None) or ())
|
|
173
|
+
except TypeError:
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _BaseWrapper:
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
tracer: Tracer,
|
|
181
|
+
span_name: str,
|
|
182
|
+
*,
|
|
183
|
+
contents: bool = False,
|
|
184
|
+
capture_urls: bool = False,
|
|
185
|
+
hide_inputs: bool = False,
|
|
186
|
+
pii: bool = False,
|
|
187
|
+
) -> None:
|
|
188
|
+
self._tracer = tracer
|
|
189
|
+
self._span_name = span_name
|
|
190
|
+
self._contents = contents
|
|
191
|
+
self._capture_urls = capture_urls
|
|
192
|
+
self._hide_inputs = hide_inputs
|
|
193
|
+
self._pii = pii
|
|
194
|
+
|
|
195
|
+
def _start_span(
|
|
196
|
+
self, instance: Any, args: Sequence[Any], kwargs: Mapping[str, Any]
|
|
197
|
+
) -> Tuple[Optional[Span], Optional[_Scrub]]:
|
|
198
|
+
"""Start the span; (None, None) runs the call untraced."""
|
|
199
|
+
scrub: Optional[_Scrub]
|
|
200
|
+
try:
|
|
201
|
+
attributes, scrub = _request_attributes(
|
|
202
|
+
instance,
|
|
203
|
+
args,
|
|
204
|
+
kwargs,
|
|
205
|
+
self._contents,
|
|
206
|
+
self._capture_urls,
|
|
207
|
+
self._hide_inputs,
|
|
208
|
+
self._pii,
|
|
209
|
+
)
|
|
210
|
+
except Exception: # an attribute must never break the user's call
|
|
211
|
+
# Unknown key and query: error text is withheld (see _error_text).
|
|
212
|
+
attributes = {_SPAN_KIND: _TOOL if self._contents else _RETRIEVER}
|
|
213
|
+
scrub = None
|
|
214
|
+
try:
|
|
215
|
+
return self._tracer.start_span(self._span_name, attributes=attributes), scrub
|
|
216
|
+
except Exception: # a tracer failure must never reach the caller
|
|
217
|
+
return None, None
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _finish_ok(span: Span, document_count: Optional[int]) -> None:
|
|
221
|
+
try:
|
|
222
|
+
if document_count is not None:
|
|
223
|
+
span.set_attribute(_RESULT_COUNT, document_count)
|
|
224
|
+
span.set_status(Status(StatusCode.OK))
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
finally:
|
|
228
|
+
span.end()
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _finish_error(span: Span, error: BaseException, scrub: Optional[_Scrub]) -> None:
|
|
232
|
+
# No document count: nothing was returned, so the count is unknown.
|
|
233
|
+
# The event is built here rather than by span.record_exception(),
|
|
234
|
+
# which calls str(error) unguarded; the span must end whatever the
|
|
235
|
+
# error's str() does, and the caller's exception must stay the same.
|
|
236
|
+
try:
|
|
237
|
+
description, event = _error_text(error, scrub)
|
|
238
|
+
event["exception.type"] = _exception_type(error)
|
|
239
|
+
event["exception.escaped"] = "False"
|
|
240
|
+
span.add_event("exception", attributes=event)
|
|
241
|
+
span.set_status(Status(StatusCode.ERROR, description))
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
finally:
|
|
245
|
+
span.end()
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _finish_cancelled(span: Span) -> None:
|
|
249
|
+
# Cancellation is not an exception: no event and no document count.
|
|
250
|
+
span.set_attribute(_CANCELLED, True)
|
|
251
|
+
span.set_status(Status(StatusCode.ERROR, "cancelled"))
|
|
252
|
+
span.end()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _exception_type(error: BaseException) -> str:
|
|
256
|
+
"""The exception type as the OpenTelemetry SDK's record_exception names it."""
|
|
257
|
+
module = type(error).__module__
|
|
258
|
+
qualname = type(error).__qualname__
|
|
259
|
+
return "{0}.{1}".format(module, qualname) if module and module != "builtins" else qualname
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _error_text(error: BaseException, scrub: Optional[_Scrub]) -> Tuple[str, dict]:
|
|
263
|
+
"""The status description and exception-event text for ``error``.
|
|
264
|
+
|
|
265
|
+
exa-py copies the server's response body into its exceptions, and a body
|
|
266
|
+
can repeat the request. The key (and, with hidden inputs, the query) is
|
|
267
|
+
removed and the message is capped at 1 KB; the caller's exception object
|
|
268
|
+
is not changed. No stack trace is exported: frames carry local file paths
|
|
269
|
+
(and so usernames and hostnames), and chained exceptions' text.
|
|
270
|
+
"""
|
|
271
|
+
name = type(error).__name__
|
|
272
|
+
if scrub is None:
|
|
273
|
+
return name, {"exception.message": _REDACTED}
|
|
274
|
+
try:
|
|
275
|
+
message = _cap(scrub(str(error)))
|
|
276
|
+
except Exception:
|
|
277
|
+
return name, {"exception.message": _REDACTED}
|
|
278
|
+
description = "{0}: {1}".format(name, message) if message else name
|
|
279
|
+
return description, {"exception.message": message}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _current(span: Span) -> Any:
|
|
283
|
+
"""Make ``span`` current for the vendor call so HTTP client spans nest under it.
|
|
284
|
+
|
|
285
|
+
The span is ended by the wrapper, not on exit, and errors are recorded once
|
|
286
|
+
by ``_finish_error``.
|
|
287
|
+
"""
|
|
288
|
+
return trace_api.use_span(
|
|
289
|
+
span, end_on_exit=False, record_exception=False, set_status_on_exception=False
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class OperationWrapper(_BaseWrapper):
|
|
294
|
+
"""Trace a synchronous Exa operation."""
|
|
295
|
+
|
|
296
|
+
def __call__(
|
|
297
|
+
self,
|
|
298
|
+
wrapped: Callable[..., Any],
|
|
299
|
+
instance: Any,
|
|
300
|
+
args: tuple,
|
|
301
|
+
kwargs: Mapping[str, Any],
|
|
302
|
+
) -> Any:
|
|
303
|
+
span, scrub = self._start_span(instance, args, kwargs)
|
|
304
|
+
if span is None:
|
|
305
|
+
return wrapped(*args, **kwargs)
|
|
306
|
+
try:
|
|
307
|
+
with _current(span):
|
|
308
|
+
result = wrapped(*args, **kwargs)
|
|
309
|
+
except BaseException as error:
|
|
310
|
+
self._finish_error(span, error, scrub)
|
|
311
|
+
raise
|
|
312
|
+
self._finish_ok(span, _document_count(result))
|
|
313
|
+
return result
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class AsyncOperationWrapper(_BaseWrapper):
|
|
317
|
+
"""Trace an asynchronous Exa operation."""
|
|
318
|
+
|
|
319
|
+
async def __call__(
|
|
320
|
+
self,
|
|
321
|
+
wrapped: Callable[..., Any],
|
|
322
|
+
instance: Any,
|
|
323
|
+
args: tuple,
|
|
324
|
+
kwargs: Mapping[str, Any],
|
|
325
|
+
) -> Any:
|
|
326
|
+
span, scrub = self._start_span(instance, args, kwargs)
|
|
327
|
+
if span is None:
|
|
328
|
+
return await wrapped(*args, **kwargs)
|
|
329
|
+
try:
|
|
330
|
+
with _current(span):
|
|
331
|
+
result = await wrapped(*args, **kwargs)
|
|
332
|
+
except asyncio.CancelledError:
|
|
333
|
+
self._finish_cancelled(span)
|
|
334
|
+
raise
|
|
335
|
+
except BaseException as error:
|
|
336
|
+
self._finish_error(span, error, scrub)
|
|
337
|
+
raise
|
|
338
|
+
self._finish_ok(span, _document_count(result))
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# Stream spans not yet ended. A stream still referenced at interpreter exit
|
|
343
|
+
# is finalised after atexit hooks, when the exporter is already shut down, so
|
|
344
|
+
# end_open_streams() ends these first (see ExaInstrumentor._instrument).
|
|
345
|
+
_OPEN_STREAMS: "weakref.WeakSet[_StreamState]" = weakref.WeakSet()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def end_open_streams() -> None:
|
|
349
|
+
"""End every still-open stream span as cancelled; registered with atexit."""
|
|
350
|
+
for state in list(_OPEN_STREAMS):
|
|
351
|
+
try:
|
|
352
|
+
state.cancelled()
|
|
353
|
+
except Exception: # exit must not fail because of tracing
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class _StreamState:
|
|
358
|
+
"""Ends one stream span exactly once; counts citations as chunks arrive."""
|
|
359
|
+
|
|
360
|
+
def __init__(self, span: Span, scrub: Optional[_Scrub]) -> None:
|
|
361
|
+
self.span = span
|
|
362
|
+
self.scrub = scrub
|
|
363
|
+
self.finished = False
|
|
364
|
+
# Citations accumulate over chunks; the total is known only at the end.
|
|
365
|
+
self.citations = 0
|
|
366
|
+
_OPEN_STREAMS.add(self)
|
|
367
|
+
|
|
368
|
+
def observe(self, chunk: Any) -> None:
|
|
369
|
+
self.citations += _chunk_citations(chunk)
|
|
370
|
+
|
|
371
|
+
def _claim(self) -> bool:
|
|
372
|
+
if self.finished:
|
|
373
|
+
return False
|
|
374
|
+
self.finished = True
|
|
375
|
+
_OPEN_STREAMS.discard(self)
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
def ok(self) -> None:
|
|
379
|
+
if self._claim():
|
|
380
|
+
_BaseWrapper._finish_ok(self.span, self.citations)
|
|
381
|
+
|
|
382
|
+
def error(self, error: BaseException) -> None:
|
|
383
|
+
if self._claim():
|
|
384
|
+
_BaseWrapper._finish_error(self.span, error, self.scrub)
|
|
385
|
+
|
|
386
|
+
def cancelled(self) -> None:
|
|
387
|
+
if self._claim():
|
|
388
|
+
_BaseWrapper._finish_cancelled(self.span)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _state_of(proxy: Any) -> Optional[_StreamState]:
|
|
392
|
+
# A proxy whose __init__ failed has no state; __del__ must not raise.
|
|
393
|
+
try:
|
|
394
|
+
return proxy._self_state
|
|
395
|
+
except Exception:
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class _TracedStream(wrapt.ObjectProxy): # type: ignore[misc]
|
|
400
|
+
"""A StreamSearchResponse/StreamAnswerResponse that ends its span.
|
|
401
|
+
|
|
402
|
+
An ObjectProxy, so ``isinstance`` against the vendor class still holds.
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
def __init__(self, response: Any, span: Span, scrub: Optional[_Scrub]) -> None:
|
|
406
|
+
super().__init__(response)
|
|
407
|
+
self._self_state = _StreamState(span, scrub)
|
|
408
|
+
self._self_iterator = None
|
|
409
|
+
|
|
410
|
+
def __iter__(self) -> "_TracedStream":
|
|
411
|
+
return self
|
|
412
|
+
|
|
413
|
+
def __next__(self) -> Any:
|
|
414
|
+
state = self._self_state
|
|
415
|
+
try:
|
|
416
|
+
if self._self_iterator is None:
|
|
417
|
+
self._self_iterator = iter(self.__wrapped__)
|
|
418
|
+
chunk = next(self._self_iterator)
|
|
419
|
+
except StopIteration:
|
|
420
|
+
state.ok()
|
|
421
|
+
raise
|
|
422
|
+
except BaseException as error:
|
|
423
|
+
state.error(error)
|
|
424
|
+
raise
|
|
425
|
+
state.observe(chunk)
|
|
426
|
+
return chunk
|
|
427
|
+
|
|
428
|
+
def close(self) -> None:
|
|
429
|
+
"""End the span as cancelled (no-op after completion), then close."""
|
|
430
|
+
self._self_state.cancelled()
|
|
431
|
+
try:
|
|
432
|
+
close = getattr(self._self_iterator, "close", None)
|
|
433
|
+
if close is not None:
|
|
434
|
+
close()
|
|
435
|
+
finally:
|
|
436
|
+
self.__wrapped__.close()
|
|
437
|
+
|
|
438
|
+
def __del__(self) -> None:
|
|
439
|
+
state = _state_of(self)
|
|
440
|
+
if state is not None:
|
|
441
|
+
state.cancelled()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class StreamWrapper(_BaseWrapper):
|
|
445
|
+
"""Trace a synchronous Exa stream for its full iterator lifetime."""
|
|
446
|
+
|
|
447
|
+
def __call__(
|
|
448
|
+
self,
|
|
449
|
+
wrapped: Callable[..., Any],
|
|
450
|
+
instance: Any,
|
|
451
|
+
args: tuple,
|
|
452
|
+
kwargs: Mapping[str, Any],
|
|
453
|
+
) -> Any:
|
|
454
|
+
span, scrub = self._start_span(instance, args, kwargs)
|
|
455
|
+
if span is None:
|
|
456
|
+
return wrapped(*args, **kwargs)
|
|
457
|
+
try:
|
|
458
|
+
with _current(span):
|
|
459
|
+
response = wrapped(*args, **kwargs)
|
|
460
|
+
except BaseException as error:
|
|
461
|
+
self._finish_error(span, error, scrub)
|
|
462
|
+
raise
|
|
463
|
+
return _TracedStream(response, span, scrub)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class _TracedAsyncStream(wrapt.ObjectProxy): # type: ignore[misc]
|
|
467
|
+
"""An AsyncStreamSearchResponse/AsyncStreamAnswerResponse that ends its span."""
|
|
468
|
+
|
|
469
|
+
def __init__(self, response: Any, span: Span, scrub: Optional[_Scrub]) -> None:
|
|
470
|
+
super().__init__(response)
|
|
471
|
+
self._self_state = _StreamState(span, scrub)
|
|
472
|
+
self._self_iterator = None
|
|
473
|
+
|
|
474
|
+
def __aiter__(self) -> "_TracedAsyncStream":
|
|
475
|
+
return self
|
|
476
|
+
|
|
477
|
+
async def __anext__(self) -> Any:
|
|
478
|
+
state = self._self_state
|
|
479
|
+
try:
|
|
480
|
+
if self._self_iterator is None:
|
|
481
|
+
self._self_iterator = self.__wrapped__.__aiter__()
|
|
482
|
+
chunk = await self._self_iterator.__anext__()
|
|
483
|
+
except StopAsyncIteration:
|
|
484
|
+
state.ok()
|
|
485
|
+
raise
|
|
486
|
+
except asyncio.CancelledError:
|
|
487
|
+
state.cancelled()
|
|
488
|
+
raise
|
|
489
|
+
except BaseException as error:
|
|
490
|
+
state.error(error)
|
|
491
|
+
raise
|
|
492
|
+
state.observe(chunk)
|
|
493
|
+
return chunk
|
|
494
|
+
|
|
495
|
+
def close(self) -> None:
|
|
496
|
+
"""End the span as cancelled, then delegate to the vendor's close().
|
|
497
|
+
|
|
498
|
+
exa-py's async close() raises RuntimeError (2.25.0: httpx's sync close on an
|
|
499
|
+
async response; 2.26.0: a pointer to aclose()); that outcome is the vendor's
|
|
500
|
+
and is not changed.
|
|
501
|
+
"""
|
|
502
|
+
self._self_state.cancelled()
|
|
503
|
+
self.__wrapped__.close()
|
|
504
|
+
|
|
505
|
+
async def aclose(self) -> None:
|
|
506
|
+
"""End the span as cancelled, then release the HTTP response."""
|
|
507
|
+
self._self_state.cancelled()
|
|
508
|
+
try:
|
|
509
|
+
close = getattr(self._self_iterator, "aclose", None)
|
|
510
|
+
if close is not None:
|
|
511
|
+
await close()
|
|
512
|
+
finally:
|
|
513
|
+
# exa-py 2.26.0 has aclose(); 2.25.0 has none, but its httpx response does.
|
|
514
|
+
response_close = getattr(self.__wrapped__, "aclose", None)
|
|
515
|
+
if response_close is None:
|
|
516
|
+
raw_response = getattr(self.__wrapped__, "_raw_response", None)
|
|
517
|
+
response_close = getattr(raw_response, "aclose", None)
|
|
518
|
+
if response_close is not None:
|
|
519
|
+
await response_close()
|
|
520
|
+
|
|
521
|
+
def __del__(self) -> None:
|
|
522
|
+
state = _state_of(self)
|
|
523
|
+
if state is not None:
|
|
524
|
+
state.cancelled()
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
class AsyncStreamWrapper(_BaseWrapper):
|
|
528
|
+
"""Trace an asynchronous Exa stream for its full iterator lifetime."""
|
|
529
|
+
|
|
530
|
+
async def __call__(
|
|
531
|
+
self,
|
|
532
|
+
wrapped: Callable[..., Any],
|
|
533
|
+
instance: Any,
|
|
534
|
+
args: tuple,
|
|
535
|
+
kwargs: Mapping[str, Any],
|
|
536
|
+
) -> Any:
|
|
537
|
+
span, scrub = self._start_span(instance, args, kwargs)
|
|
538
|
+
if span is None:
|
|
539
|
+
response = wrapped(*args, **kwargs)
|
|
540
|
+
return await response if inspect.isawaitable(response) else response
|
|
541
|
+
try:
|
|
542
|
+
with _current(span):
|
|
543
|
+
response = wrapped(*args, **kwargs)
|
|
544
|
+
if inspect.isawaitable(response):
|
|
545
|
+
response = await response
|
|
546
|
+
except asyncio.CancelledError:
|
|
547
|
+
self._finish_cancelled(span)
|
|
548
|
+
raise
|
|
549
|
+
except BaseException as error:
|
|
550
|
+
self._finish_error(span, error, scrub)
|
|
551
|
+
raise
|
|
552
|
+
return _TracedAsyncStream(response, span, scrub)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0" # x-release-please-version
|