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.
@@ -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,2 @@
1
+ # Tested against exa-py 2.25.0; any later 2.x is accepted.
2
+ _instruments = ("exa-py >= 2.25.0, < 3",)
@@ -0,0 +1 @@
1
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0" # x-release-please-version