agentrail-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentrail_python/__init__.py +64 -0
- agentrail_python/_client.py +362 -0
- agentrail_python/_handoff.py +214 -0
- agentrail_python/_init.py +64 -0
- agentrail_python/_options.py +305 -0
- agentrail_python/_outbound.py +76 -0
- agentrail_python/_performance.py +63 -0
- agentrail_python/_privacy.py +255 -0
- agentrail_python/_processors.py +259 -0
- agentrail_python/_queue.py +236 -0
- agentrail_python/_runtime.py +110 -0
- agentrail_python/_suppression.py +33 -0
- agentrail_python/_text.py +49 -0
- agentrail_python/_version.py +2 -0
- agentrail_python/asgi.py +111 -0
- agentrail_python/httpx.py +159 -0
- agentrail_python/logging.py +70 -0
- agentrail_python/mcp.py +125 -0
- agentrail_python/py.typed +0 -0
- agentrail_python/requests.py +67 -0
- agentrail_python/wsgi.py +158 -0
- agentrail_python-0.1.0.dist-info/METADATA +625 -0
- agentrail_python-0.1.0.dist-info/RECORD +25 -0
- agentrail_python-0.1.0.dist-info/WHEEL +4 -0
- agentrail_python-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agentrail-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agentrail for Python backends: HTTP, identity, milestones, logs and MCP for agent journeys, over OpenTelemetry.
|
|
5
|
+
Project-URL: Homepage, https://www.agentrail.co
|
|
6
|
+
Project-URL: Documentation, https://www.agentrail.co/docs/guides/journeys-python
|
|
7
|
+
Project-URL: Repository, https://github.com/agentrail-co/agentrail-python
|
|
8
|
+
Project-URL: Issues, https://github.com/agentrail-co/agentrail-python/issues
|
|
9
|
+
Author: Agentrail
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agentrail,agents,asgi,mcp,observability,opentelemetry,wsgi
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: System :: Monitoring
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: opentelemetry-api<1.46,>=1.45.1
|
|
26
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http<1.46,>=1.45.1
|
|
27
|
+
Requires-Dist: opentelemetry-sdk<1.46,>=1.45.1
|
|
28
|
+
Provides-Extra: httpx
|
|
29
|
+
Requires-Dist: httpx<1,>=0.28; extra == 'httpx'
|
|
30
|
+
Provides-Extra: mcp
|
|
31
|
+
Requires-Dist: mcp<3,>=2.3; extra == 'mcp'
|
|
32
|
+
Provides-Extra: requests
|
|
33
|
+
Requires-Dist: requests<3,>=2.32; extra == 'requests'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# Agentrail for Python
|
|
37
|
+
|
|
38
|
+
[](https://github.com/agentrail-co/agentrail-python/actions/workflows/ci.yml)
|
|
39
|
+
[](https://pypi.org/project/agentrail-python/)
|
|
40
|
+
[](https://pypi.org/project/agentrail-python/)
|
|
41
|
+
|
|
42
|
+
`agentrail-python` records what happens inside your Python backend when an AI
|
|
43
|
+
agent (or a person) uses your product, so [Agentrail](https://www.agentrail.co)
|
|
44
|
+
can link it into that visitor's journey. It records:
|
|
45
|
+
|
|
46
|
+
- each incoming HTTP request and each outgoing call;
|
|
47
|
+
- who the request was for, once your application has authenticated them;
|
|
48
|
+
- the business steps you name (`checkout.confirm`, `delivery.requested`);
|
|
49
|
+
- structured logs, tied to the request that wrote them;
|
|
50
|
+
- MCP tool, resource and prompt calls.
|
|
51
|
+
|
|
52
|
+
It sends OpenTelemetry (OTLP over HTTP/protobuf) to the Agentrail collector.
|
|
53
|
+
By default it sends no request or response bodies, no arguments, no query
|
|
54
|
+
values and no raw identities.
|
|
55
|
+
|
|
56
|
+
Works with FastAPI, Starlette, Django, Flask and any other ASGI or WSGI
|
|
57
|
+
application. Requires **Python 3.10 or later**.
|
|
58
|
+
|
|
59
|
+
- [Install](#install)
|
|
60
|
+
- [Quickstart](#quickstart): [FastAPI](#fastapi), [Starlette and other ASGI](#starlette-and-other-asgi-apps),
|
|
61
|
+
[Django](#django), [Flask and other WSGI](#flask-and-other-wsgi-apps)
|
|
62
|
+
- [Outgoing calls](#outgoing-calls)
|
|
63
|
+
- [Identity and milestones](#identity-and-milestones)
|
|
64
|
+
- [Workflow and plugin evidence](#workflow-and-plugin-evidence)
|
|
65
|
+
- [Logs](#logs)
|
|
66
|
+
- [MCP servers](#mcp-servers)
|
|
67
|
+
- [An existing OpenTelemetry setup](#an-existing-opentelemetry-setup)
|
|
68
|
+
- [Approximate IP location](#approximate-ip-location)
|
|
69
|
+
- [Your own servers](#your-own-servers)
|
|
70
|
+
- [Delivery, shutdown and diagnostics](#delivery-shutdown-and-diagnostics)
|
|
71
|
+
- [What is sent, and what is not](#what-is-sent-and-what-is-not)
|
|
72
|
+
- [Node, Go and Python](#node-go-and-python)
|
|
73
|
+
- [Configuration reference](#configuration-reference)
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
pip install agentrail-python
|
|
79
|
+
# with the integrations you use:
|
|
80
|
+
pip install "agentrail-python[httpx,requests,mcp]"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Then, in Agentrail, open your workspace's journey setup and create a backend
|
|
84
|
+
source. In the setup screen this source type is called **Node**; it is the
|
|
85
|
+
backend source for every backend SDK, Python included. You need two values,
|
|
86
|
+
and both stay on your server, never in a page:
|
|
87
|
+
|
|
88
|
+
- the **source secret**, which authenticates what your backend sends;
|
|
89
|
+
- the workspace **hash key**, which hashes identities before they leave your
|
|
90
|
+
process and signs trace context between your services.
|
|
91
|
+
|
|
92
|
+
## Quickstart
|
|
93
|
+
|
|
94
|
+
Create the client once, when the process starts, before it serves requests:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
# agentrail_setup.py
|
|
98
|
+
import os
|
|
99
|
+
|
|
100
|
+
import agentrail_python as ar
|
|
101
|
+
|
|
102
|
+
agentrail = ar.init(
|
|
103
|
+
ar.Options(
|
|
104
|
+
collector=ar.CollectorOptions(
|
|
105
|
+
endpoint="https://api.agentrail.co/ingest/otlp",
|
|
106
|
+
secret=os.environ["AGENTRAIL_SOURCE_SECRET"],
|
|
107
|
+
),
|
|
108
|
+
hash_key=os.environ["AGENTRAIL_HASH_KEY"],
|
|
109
|
+
service_name="orders-api",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`init` installs Agentrail's OpenTelemetry tracer provider, logger provider and
|
|
115
|
+
propagator for the process. If your process already has an OpenTelemetry
|
|
116
|
+
provider (Sentry, Datadog, your own), `init` refuses to replace it: use
|
|
117
|
+
[`create_processors`](#an-existing-opentelemetry-setup) instead. Calling `init`
|
|
118
|
+
again with the same `Options` object returns the same client.
|
|
119
|
+
|
|
120
|
+
The SDK reads no environment variables itself. Read and check your own
|
|
121
|
+
configuration before calling `init`; a missing value is a configuration error,
|
|
122
|
+
not a default.
|
|
123
|
+
|
|
124
|
+
### FastAPI
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from fastapi import Depends, FastAPI
|
|
128
|
+
|
|
129
|
+
import agentrail_python as ar
|
|
130
|
+
from agentrail_python.asgi import AgentrailASGIMiddleware
|
|
131
|
+
from agentrail_setup import agentrail
|
|
132
|
+
from my_app.auth import current_user # your own authentication
|
|
133
|
+
|
|
134
|
+
app = FastAPI()
|
|
135
|
+
app.add_middleware(AgentrailASGIMiddleware, client=agentrail)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.post("/orders/{order_id}/confirm")
|
|
139
|
+
async def confirm(order_id: str, user=Depends(current_user)):
|
|
140
|
+
agentrail.identify(ar.VerifiedIdentity(kind="user", id=user.id))
|
|
141
|
+
...
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The route template (`/orders/{order_id}/confirm`) is recorded with each request.
|
|
145
|
+
|
|
146
|
+
### Starlette and other ASGI apps
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
app = agentrail.asgi(app) # Starlette, Quart, Litestar, any ASGI 3 application
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Django
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
# asgi.py
|
|
156
|
+
from django.core.asgi import get_asgi_application
|
|
157
|
+
from agentrail_setup import agentrail
|
|
158
|
+
|
|
159
|
+
application = agentrail.asgi(get_asgi_application())
|
|
160
|
+
|
|
161
|
+
# or wsgi.py
|
|
162
|
+
from django.core.wsgi import get_wsgi_application
|
|
163
|
+
application = agentrail.wsgi(get_wsgi_application())
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Flask and other WSGI apps
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from flask import Flask
|
|
170
|
+
from agentrail_setup import agentrail
|
|
171
|
+
|
|
172
|
+
app = Flask(__name__)
|
|
173
|
+
app.wsgi_app = agentrail.wsgi(app.wsgi_app)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Install the middleware once, around the part of your application you want
|
|
177
|
+
recorded. If you install it after your authentication middleware, the
|
|
178
|
+
authentication step itself is not recorded. Each request becomes one entry
|
|
179
|
+
step; work it does (database calls, other OpenTelemetry spans) is recorded
|
|
180
|
+
beneath it.
|
|
181
|
+
|
|
182
|
+
**Trace context from callers.** A request carrying a valid signed handoff
|
|
183
|
+
from your own Agentrail edge or another backend SDK continues that trace. The
|
|
184
|
+
signature covers the method, the exact path and query as sent, the workspace
|
|
185
|
+
key ID, the trace parent and an issue time that is valid for 60 seconds, so
|
|
186
|
+
keep your servers' clocks in sync. A request without one (an agent, a browser,
|
|
187
|
+
anyone on the internet) starts a new trace; a `traceparent` it sends is kept
|
|
188
|
+
only as a link, never as the parent.
|
|
189
|
+
|
|
190
|
+
For WSGI, the exact target comes from `RAW_URI` or `REQUEST_URI`, which
|
|
191
|
+
gunicorn, uWSGI and Werkzeug set. Under a server that does not, the target is
|
|
192
|
+
rebuilt from the decoded path, and a signed handoff to a path with
|
|
193
|
+
percent-encoding may not verify.
|
|
194
|
+
|
|
195
|
+
## Outgoing calls
|
|
196
|
+
|
|
197
|
+
Wrap the HTTP clients your handlers use, so their calls are recorded and,
|
|
198
|
+
where you allow it, carry signed trace context to your own services:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
import httpx
|
|
202
|
+
import requests
|
|
203
|
+
from agentrail_python.httpx import async_transport, transport
|
|
204
|
+
from agentrail_python.requests import adapter
|
|
205
|
+
|
|
206
|
+
client = httpx.Client(transport=transport(agentrail))
|
|
207
|
+
async_client = httpx.AsyncClient(transport=async_transport(agentrail))
|
|
208
|
+
|
|
209
|
+
session = requests.Session()
|
|
210
|
+
session.mount("https://", adapter(agentrail))
|
|
211
|
+
session.mount("http://", adapter(agentrail))
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Signed context goes only to the exact origins you list:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
ar.Options(..., outbound_origins=["https://inventory.internal", "http://billing:8080"])
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
An origin is a lowercase scheme and host with a non-default port, nothing
|
|
221
|
+
else. The destination a call actually connects to decides, not a `Host`
|
|
222
|
+
header. Each redirect is checked again, so a redirect from an allowed origin
|
|
223
|
+
to another one is not signed. Managed calls never forward `traceparent`,
|
|
224
|
+
`tracestate`, `baggage`, `sentry-trace` or a handoff header they did not sign
|
|
225
|
+
themselves. Calls made outside a request or milestone pass through untouched.
|
|
226
|
+
|
|
227
|
+
`transport(agentrail, base)` wraps your own `httpx.HTTPTransport` (for proxies,
|
|
228
|
+
retries or certificates); `adapter(agentrail, **kwargs)` takes the same
|
|
229
|
+
arguments as `requests.adapters.HTTPAdapter`. A `requests` call's step ends
|
|
230
|
+
when `send` returns: with `stream=True` it does not wait for the body.
|
|
231
|
+
|
|
232
|
+
## Identity and milestones
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
# Only after your application has authenticated this caller:
|
|
236
|
+
agentrail.identify(
|
|
237
|
+
ar.VerifiedIdentity(
|
|
238
|
+
kind="agent_registration", # user, agent_registration, agent_instance, oauth_client, api_key
|
|
239
|
+
id=authenticated_agent.id,
|
|
240
|
+
acting_user_id=authenticated_agent.user_id, # optional
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
IDs are hashed with the workspace hash key before they leave the process.
|
|
246
|
+
Nothing a caller merely claims (a header, a user agent, MCP client details)
|
|
247
|
+
is ever treated as identity. `identify` works inside a request or milestone;
|
|
248
|
+
outside one it reports `identity_outside_request` and records nothing. The
|
|
249
|
+
first identity in a request stands: repeating it does nothing, and a
|
|
250
|
+
different one is recorded as its own `auth` step.
|
|
251
|
+
|
|
252
|
+
Name the business steps that matter:
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
step = ar.MilestoneContext(surface="custom", kind="checkout.confirm")
|
|
256
|
+
|
|
257
|
+
agentrail.milestone("checkout.confirm", step) # an instant step
|
|
258
|
+
|
|
259
|
+
with agentrail.start_milestone("checkout.confirm", step): # or `async with`
|
|
260
|
+
confirm_order()
|
|
261
|
+
|
|
262
|
+
@agentrail.with_milestone("checkout.confirm", step) # each call is one step
|
|
263
|
+
async def confirm_order(): ...
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
A milestone's exception is recorded (its class name only) and raised
|
|
267
|
+
unchanged; its return value is untouched. Names and kinds are stable labels of
|
|
268
|
+
1 to 256 characters, never IDs or arguments. Outside a request, a milestone
|
|
269
|
+
starts its own trace; an unrelated span that was current becomes a link.
|
|
270
|
+
|
|
271
|
+
The current request or milestone follows your code through `await`, asyncio
|
|
272
|
+
tasks and `asyncio.to_thread`. A thread you start yourself does not inherit
|
|
273
|
+
it; run its work with `contextvars.copy_context().run(...)`.
|
|
274
|
+
|
|
275
|
+
## Workflow and plugin evidence
|
|
276
|
+
|
|
277
|
+
Once your application has authorized a business operation, pass its operation
|
|
278
|
+
reference to the milestone. Use a new reference for each attempt, and the same
|
|
279
|
+
one for its start, progress, plugin calls and outcome, across services that
|
|
280
|
+
share the workspace hash key and namespace:
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
agentrail.milestone(
|
|
284
|
+
"delivery.requested",
|
|
285
|
+
ar.MilestoneContext(
|
|
286
|
+
surface="custom",
|
|
287
|
+
kind="business.event",
|
|
288
|
+
performance=ar.PerformanceContext(
|
|
289
|
+
environment="production", # or "test"
|
|
290
|
+
operation=ar.AuthorizedOperation(
|
|
291
|
+
namespace_id=OPERATION_NAMESPACE_ID,
|
|
292
|
+
instance_ref=delivery_attempt.id,
|
|
293
|
+
role="start", # start, progress or terminal
|
|
294
|
+
),
|
|
295
|
+
event_name="delivery.requested",
|
|
296
|
+
plugin=ar.PluginInvocation(binding_id=PLUGIN_BINDING_ID, invocation_id=str(uuid.uuid4())),
|
|
297
|
+
),
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Record a `terminal` milestone with your configured success event only after
|
|
303
|
+
your backend has confirmed the outcome. A tool's result or an HTTP 200 is not
|
|
304
|
+
business completion, and the MCP integration never declares one.
|
|
305
|
+
|
|
306
|
+
The operation reference never leaves your process: the SDK sends
|
|
307
|
+
HMAC-SHA256 under the workspace hash key of the JSON array
|
|
308
|
+
`["agentrail.operation", namespace_id, instance_ref]`, the same as Node and Go,
|
|
309
|
+
so steps from all three join. Supply `operation` with `event_name`, `operation`
|
|
310
|
+
with `plugin`, or `plugin` alone (plugin activity with no workflow credit).
|
|
311
|
+
Namespace and binding IDs come from Agentrail. Invalid evidence is left off the
|
|
312
|
+
step and reported as `invalid_performance`; the milestone and your code still
|
|
313
|
+
run. Evidence applies only to that milestone, never to its child spans.
|
|
314
|
+
|
|
315
|
+
## Logs
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
import logging
|
|
319
|
+
from agentrail_python.logging import AgentrailHandler
|
|
320
|
+
|
|
321
|
+
logging.getLogger().addHandler(AgentrailHandler(agentrail))
|
|
322
|
+
|
|
323
|
+
log = logging.getLogger("orders")
|
|
324
|
+
log.error("payment declined", extra={"event.name": "checkout.failed"})
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
By default a log sends its severity, event name, the exception's class name
|
|
328
|
+
(`error.type`) and the exact request and span it was written in. Message text
|
|
329
|
+
and other fields are sent only when you name them:
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
ar.Options(..., logs=ar.LogsOptions(include_fields=["message", "order_status"]))
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Only the named top-level fields with scalar values (text, numbers, booleans,
|
|
336
|
+
null) are sent, in the order named, as a JSON object of at most 4,096
|
|
337
|
+
characters; a long value is shortened to fit, and a field that cannot fit is
|
|
338
|
+
left out. `message` is the formatted message. Never name fields holding
|
|
339
|
+
credentials, identities or customer content. Set
|
|
340
|
+
`LogsOptions(exclude_uncorrelated=True)` to send only logs written inside a
|
|
341
|
+
request or milestone.
|
|
342
|
+
|
|
343
|
+
You can also record a log directly, or add `processors.log_processor` to your
|
|
344
|
+
own OpenTelemetry `LoggerProvider`:
|
|
345
|
+
|
|
346
|
+
```python
|
|
347
|
+
agentrail.log(ar.StructuredLog(severity_number=17, error_type="PaymentDeclined", event_name="checkout.failed"))
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Logs never prove anything about a journey on their own: they describe the step
|
|
351
|
+
they belong to.
|
|
352
|
+
|
|
353
|
+
## MCP servers
|
|
354
|
+
|
|
355
|
+
For servers built with the official [`mcp`](https://pypi.org/project/mcp/)
|
|
356
|
+
SDK (2.x, `MCPServer`):
|
|
357
|
+
|
|
358
|
+
```python
|
|
359
|
+
from mcp.server.mcpserver import MCPServer
|
|
360
|
+
from agentrail_python.mcp import mcp_capture
|
|
361
|
+
|
|
362
|
+
capture = mcp_capture(agentrail)
|
|
363
|
+
server = MCPServer("orders", middleware=[capture.middleware])
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def checkout(item: str) -> str: ...
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
server.tool(name="checkout")(capture.tool("checkout", checkout))
|
|
370
|
+
server.resource("catalog://items", name="catalog")(capture.resource("catalog", read_catalog))
|
|
371
|
+
server.prompt(name="greet")(capture.prompt("greet", greet))
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Each call becomes an MCP step named `mcp.tools/call:checkout`. Behind the
|
|
375
|
+
ASGI middleware, `agentrail.asgi(server.streamable_http_app(stateless_http=True))`,
|
|
376
|
+
each call nests under its HTTP request (this is the tested setup). Where the
|
|
377
|
+
call runs outside a recorded request (stdio, or a session task the request did
|
|
378
|
+
not start), it starts its own trace. The middleware adds the calling client's name and version and the
|
|
379
|
+
protocol version; that is what the client says about itself, never an
|
|
380
|
+
identity. A result with `isError` marks the step failed and is returned
|
|
381
|
+
unchanged. Pass the name you registered, never a requested resource URI.
|
|
382
|
+
Arguments, results, prompt text and resource URIs are never recorded.
|
|
383
|
+
|
|
384
|
+
The MCP SDK opens its own OpenTelemetry span for every message. Inside a
|
|
385
|
+
recorded request it is kept as supporting work, not as a second entry.
|
|
386
|
+
|
|
387
|
+
## An existing OpenTelemetry setup
|
|
388
|
+
|
|
389
|
+
If your process already owns an OpenTelemetry tracer provider, build
|
|
390
|
+
Agentrail's pieces into it when you construct it. A running provider cannot
|
|
391
|
+
recover spans its sampler already dropped.
|
|
392
|
+
|
|
393
|
+
```python
|
|
394
|
+
from opentelemetry import propagate, trace
|
|
395
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
396
|
+
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
|
|
397
|
+
|
|
398
|
+
processors = ar.create_processors(
|
|
399
|
+
ar.Options(..., propagator=propagate.get_global_textmap())
|
|
400
|
+
)
|
|
401
|
+
provider = TracerProvider(
|
|
402
|
+
sampler=processors.sampler(ParentBased(TraceIdRatioBased(0.1))),
|
|
403
|
+
resource=your_resource,
|
|
404
|
+
)
|
|
405
|
+
provider.add_span_processor(your_processor)
|
|
406
|
+
provider.add_span_processor(processors.span_processor)
|
|
407
|
+
trace.set_tracer_provider(provider)
|
|
408
|
+
propagate.set_global_textmap(processors.propagator)
|
|
409
|
+
your_logger_provider.add_log_record_processor(processors.log_processor)
|
|
410
|
+
|
|
411
|
+
agentrail = processors.bind(provider) # once
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
`create_processors` registers nothing globally, and Agentrail never changes
|
|
415
|
+
what your other processors and exporters receive. The sampler wrapper keeps
|
|
416
|
+
spans inside a recorded request that your sampler would drop as
|
|
417
|
+
**record-only**, with your sampled flag unchanged: exporters that send only
|
|
418
|
+
sampled spans behave as before. Pass your existing propagator as
|
|
419
|
+
`propagator` and register the returned `processors.propagator`: it hands
|
|
420
|
+
unmanaged work to yours, and keeps unsigned headers out of recorded requests.
|
|
421
|
+
Use Agentrail's ASGI or WSGI middleware for incoming requests instead of
|
|
422
|
+
another incoming HTTP instrumentation, so each request has one entry.
|
|
423
|
+
`agentrail.shutdown()` never shuts down your provider.
|
|
424
|
+
|
|
425
|
+
## Approximate IP location
|
|
426
|
+
|
|
427
|
+
```python
|
|
428
|
+
def location(request: ar.IncomingRequest) -> ar.IPLocation | None:
|
|
429
|
+
country = request.headers.get("cf-ipcountry") # trust it only behind Cloudflare
|
|
430
|
+
if not country or not country.isalpha() or country == "XX":
|
|
431
|
+
return None
|
|
432
|
+
return ar.IPLocation(country=country, source="cloudflare")
|
|
433
|
+
|
|
434
|
+
ar.Options(..., incoming_location=location)
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
The hook runs once per recorded request, before your handler, and must return
|
|
438
|
+
quickly: read trusted platform headers or a local database loaded at startup,
|
|
439
|
+
never call a network service. Resolve the client address with your own proxy
|
|
440
|
+
configuration and never trust an arbitrary forwarded header. `country` is a
|
|
441
|
+
two-letter uppercase code; `region` and `city` are shortened to 128
|
|
442
|
+
characters; `source` is `cloudflare`, `vercel`, `dbip` or `source` (another
|
|
443
|
+
trusted provider). Return `None` when there is no location. A hook that raises
|
|
444
|
+
or returns an invalid location is reported as `location_failed`, and the
|
|
445
|
+
request carries on without one. The location is sent on the request's entry
|
|
446
|
+
and its milestones, never the address itself. An approximate location
|
|
447
|
+
describes a network exit, which may be a VPN, a proxy or an agent's host, not
|
|
448
|
+
the person.
|
|
449
|
+
|
|
450
|
+
## Your own servers
|
|
451
|
+
|
|
452
|
+
Calls between your own services are not visitors or agents. Leave them out
|
|
453
|
+
with `ignore_incoming_request`, and check something only your servers can
|
|
454
|
+
send, such as a shared secret: a user agent or an address is what any caller
|
|
455
|
+
can claim.
|
|
456
|
+
|
|
457
|
+
```python
|
|
458
|
+
import hmac
|
|
459
|
+
import os
|
|
460
|
+
|
|
461
|
+
internal_secret = os.environ["INTERNAL_SECRET"]
|
|
462
|
+
if not internal_secret:
|
|
463
|
+
raise RuntimeError("INTERNAL_SECRET is empty")
|
|
464
|
+
|
|
465
|
+
ar.Options(
|
|
466
|
+
...,
|
|
467
|
+
ignore_incoming_request=lambda request: hmac.compare_digest(
|
|
468
|
+
request.headers.get("x-my-internal", "").encode(), internal_secret.encode()
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Check the secret is set before starting: if it were empty, every request
|
|
474
|
+
without the header would match and nothing would be recorded. Compare bytes:
|
|
475
|
+
`hmac.compare_digest` raises for text that is not ASCII. A hook that raises is
|
|
476
|
+
reported as `ignore_failed`, and that request is recorded like any other. Use the same
|
|
477
|
+
hook for health checks. If the collector itself runs in this process, exclude
|
|
478
|
+
its ingestion routes here too.
|
|
479
|
+
|
|
480
|
+
## Delivery, shutdown and diagnostics
|
|
481
|
+
|
|
482
|
+
Records wait in a bounded in-memory queue per signal (traces, logs) and are
|
|
483
|
+
sent by one background thread each:
|
|
484
|
+
|
|
485
|
+
| Setting (`QueueOptions`) | Default |
|
|
486
|
+
| --- | --- |
|
|
487
|
+
| `max_records` (including the batch being sent) | 2048 |
|
|
488
|
+
| `batch_size` | 128 |
|
|
489
|
+
| `scheduled_delay` | 1 s |
|
|
490
|
+
| `export_timeout` | 5 s |
|
|
491
|
+
| `flush_timeout`, `shutdown_timeout` | 10 s |
|
|
492
|
+
|
|
493
|
+
One batch is sent at a time. The OTLP exporter retries retryable answers
|
|
494
|
+
within `export_timeout`; a refusal, or a batch still failing at the deadline,
|
|
495
|
+
is dropped and reported. Nothing is written to disk.
|
|
496
|
+
|
|
497
|
+
```python
|
|
498
|
+
agentrail.flush() # waits for attempts on what was recorded so far
|
|
499
|
+
agentrail.shutdown() # drains once and stops; terminal and idempotent
|
|
500
|
+
agentrail.diagnostics() # accepted, exported, dropped, pending per signal
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
Stop accepting requests and let them finish, then call `shutdown()`
|
|
504
|
+
(`await asyncio.to_thread(agentrail.shutdown)` from async code). With `init`,
|
|
505
|
+
the providers also drain when the interpreter exits. A finished `flush` means
|
|
506
|
+
the attempts ended; check `diagnostics()` and `on_error` for anything lost.
|
|
507
|
+
|
|
508
|
+
`on_error` receives a `CaptureError` with a `code`, `signal` and `count`, and
|
|
509
|
+
never any customer data:
|
|
510
|
+
|
|
511
|
+
| Code | Meaning |
|
|
512
|
+
| --- | --- |
|
|
513
|
+
| `queue_full`, `closed` | A record was refused: the queue was full, or after `shutdown` |
|
|
514
|
+
| `export_failed`, `deadline` | A batch was refused or failed, or a flush or shutdown deadline passed |
|
|
515
|
+
| `handoff_refused` | A signed handoff did not verify (or one could not be signed) |
|
|
516
|
+
| `entry_not_recording` | A sampler dropped an entry that should have been recorded |
|
|
517
|
+
| `location_failed` | The location hook raised or returned an invalid location |
|
|
518
|
+
| `ignore_failed` | `ignore_incoming_request` raised; the request was recorded |
|
|
519
|
+
| `identity_outside_request` | `identify` was called outside a request or milestone |
|
|
520
|
+
| `invalid_performance` | Workflow or plugin evidence was invalid and left off |
|
|
521
|
+
|
|
522
|
+
Without `on_error`, these go to standard error. The callback runs without
|
|
523
|
+
capture, so logging from it is never recorded. Capture problems never change
|
|
524
|
+
your application's response. Invalid options, identities and milestone labels
|
|
525
|
+
are programming errors and raise `ConfigurationError`.
|
|
526
|
+
|
|
527
|
+
## What is sent, and what is not
|
|
528
|
+
|
|
529
|
+
Sent, from an allowlist:
|
|
530
|
+
|
|
531
|
+
- for each request and outgoing call: the method, the URL without its query
|
|
532
|
+
(except valid Agentrail `h` markers), the route template, the status code,
|
|
533
|
+
the user agent and the error class name;
|
|
534
|
+
- for supporting spans: only their place in the trace, their timing, status
|
|
535
|
+
and error class name; their names become `backend.operation`;
|
|
536
|
+
- hashed identities, milestone names and kinds, workflow and plugin evidence,
|
|
537
|
+
MCP client details;
|
|
538
|
+
- the resource: service name, instance ID, SDK name and version, and the hash
|
|
539
|
+
key's ID (never the key).
|
|
540
|
+
|
|
541
|
+
Never sent: request or response bodies, query values, headers, cookies,
|
|
542
|
+
arguments, results, exception messages, span events, other span attributes,
|
|
543
|
+
trace state, baggage, raw identities, raw operation references, prompt text or
|
|
544
|
+
resource URIs. Log text only as described in [Logs](#logs). Your service
|
|
545
|
+
names, routes, milestone names and the fields you select are yours to keep
|
|
546
|
+
free of sensitive data.
|
|
547
|
+
|
|
548
|
+
## Node, Go and Python
|
|
549
|
+
|
|
550
|
+
The three backend SDKs send the same events and share their signing, hashing
|
|
551
|
+
and marker rules, checked against the same test vectors, so services in any
|
|
552
|
+
mix of the three link into one journey.
|
|
553
|
+
|
|
554
|
+
| | Node (`agentrail-node`) | Go (`agentrail-go`) | Python (`agentrail-python`) |
|
|
555
|
+
| --- | --- | --- | --- |
|
|
556
|
+
| Incoming HTTP | Node HTTP instrumentation | `Middleware` | ASGI and WSGI middleware |
|
|
557
|
+
| Outgoing HTTP | `http`, `fetch` | `Transport` | `httpx`, `requests` |
|
|
558
|
+
| Signed handoffs, allowed origins | Yes | Yes | Yes |
|
|
559
|
+
| Verified identity | `identify` | `Identify` | `identify` |
|
|
560
|
+
| Milestones | `milestone` | `Milestone`, `StartMilestone`, `WithMilestone` | `milestone`, `start_milestone`, `with_milestone` |
|
|
561
|
+
| Workflow and plugin evidence | Yes | Yes | Yes |
|
|
562
|
+
| Logs | Pino, OpenTelemetry | `slog`, OpenTelemetry | `logging`, OpenTelemetry |
|
|
563
|
+
| MCP | `@modelcontextprotocol/server` | official Go SDK | official `mcp` SDK |
|
|
564
|
+
| Existing OpenTelemetry | `createProcessors` | `NewProcessors` + `Bind` | `create_processors` + `bind` |
|
|
565
|
+
| Standalone startup | `init` (installs globals) | `New` (no globals) | `init` (installs globals) |
|
|
566
|
+
| IP location, ignored requests | Yes | Yes | Yes |
|
|
567
|
+
| Queue defaults | 2048 / 128 / 1 s / 5 s / 10 s | same | same |
|
|
568
|
+
|
|
569
|
+
For the edge and the page itself, use the JavaScript packages
|
|
570
|
+
[`agentrail-edge` and `agentrail-browser`](https://github.com/agentrail-co/agentrail-js).
|
|
571
|
+
|
|
572
|
+
## Configuration reference
|
|
573
|
+
|
|
574
|
+
`Options` (all keyword arguments):
|
|
575
|
+
|
|
576
|
+
| Option | |
|
|
577
|
+
| --- | --- |
|
|
578
|
+
| `collector` | `CollectorOptions(endpoint=..., secret=...)`: the OTLP base URL (`/v1/traces` and `/v1/logs` are added) and the backend source secret |
|
|
579
|
+
| `hash_key` | The workspace hash key |
|
|
580
|
+
| `service_name` | A stable, non-sensitive service name |
|
|
581
|
+
| `service_instance_id` | Optional; a random ID per process by default |
|
|
582
|
+
| `outbound_origins` | Exact origins that receive signed trace context; none by default |
|
|
583
|
+
| `ignore_incoming_request` | `(IncomingRequest) -> bool`; True leaves the request unrecorded |
|
|
584
|
+
| `incoming_location` | `(IncomingRequest) -> IPLocation \| None` |
|
|
585
|
+
| `logs` | `LogsOptions(include_fields=..., exclude_uncorrelated=...)` |
|
|
586
|
+
| `queue` | `QueueOptions(...)`, see above |
|
|
587
|
+
| `propagator` | Your existing propagator, for unmanaged work |
|
|
588
|
+
| `on_error` | `(CaptureError) -> None` |
|
|
589
|
+
|
|
590
|
+
`IncomingRequest` has `method`, `target` (path and query as sent), `scheme`,
|
|
591
|
+
`host`, `headers` (lowercase names), `client` (the peer address the server
|
|
592
|
+
saw) and `raw` (the ASGI scope or WSGI environ).
|
|
593
|
+
|
|
594
|
+
Before 1.0, a minor version (0.1 to 0.2) may change the API.
|
|
595
|
+
|
|
596
|
+
## Development
|
|
597
|
+
|
|
598
|
+
```sh
|
|
599
|
+
uv sync
|
|
600
|
+
uv run ruff format --check src tests integration scripts
|
|
601
|
+
uv run ruff check src tests integration scripts
|
|
602
|
+
uv run pyright
|
|
603
|
+
uv run pytest
|
|
604
|
+
scripts/check-consumer.sh # the built wheel, FastAPI and Flask, in a fresh venv
|
|
605
|
+
ARGUS_DIR=/path/to/argus integration/run.sh # the real collector, with Docker
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
The receiver gate needs a checkout of Agentrail's collector with its
|
|
609
|
+
dependencies installed and Docker running; see
|
|
610
|
+
[integration/README.md](integration/README.md). Shared test vectors and their
|
|
611
|
+
provenance are in [testdata/](testdata/README.md).
|
|
612
|
+
|
|
613
|
+
## Releasing
|
|
614
|
+
|
|
615
|
+
A `v<version>` tag publishes the package to PyPI
|
|
616
|
+
(`.github/workflows/release.yml`). The tag must match `VERSION` in
|
|
617
|
+
`src/agentrail_python/_version.py`. Only the repository's admins can push a
|
|
618
|
+
`v*` tag, and the publish job waits for approval in the `pypi` environment.
|
|
619
|
+
PyPI trusts the workflow (trusted publishing), so no token is stored, and each
|
|
620
|
+
file gets a signed attestation. A run that uploaded some files and then failed
|
|
621
|
+
can be run again: it skips what PyPI already has.
|
|
622
|
+
|
|
623
|
+
## Licence
|
|
624
|
+
|
|
625
|
+
[Apache-2.0](LICENSE).
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
agentrail_python/__init__.py,sha256=veqY5PqTyXPxqEq-nvEJVO_qTFiJSw2D4kj4K7PZVUk,1498
|
|
2
|
+
agentrail_python/_client.py,sha256=0gJFeKB7wJJHqvLqPehparPG64JkIplPskv229GrFbY,14443
|
|
3
|
+
agentrail_python/_handoff.py,sha256=d33yP8alwexhopgwMkyK1VFq_IJ_dU-12qxmkKUmNQc,6983
|
|
4
|
+
agentrail_python/_init.py,sha256=7F7Z_U-eC2not3C7r4CfSeUc7bMj-L09mJ52YFucYDg,2945
|
|
5
|
+
agentrail_python/_options.py,sha256=2Db-ZlAVeoHF6_ElsYNwVLoVNahruYr3kXeRWBnEbas,10102
|
|
6
|
+
agentrail_python/_outbound.py,sha256=IGGnqs1yTAoMLXuVr7xdxNNy2UmC-_mopx1UzLEgz4o,2824
|
|
7
|
+
agentrail_python/_performance.py,sha256=d42OslVWBLCXrE4VmUqrQ-V2K_7ntSVOQDOVj5hhx9Q,3228
|
|
8
|
+
agentrail_python/_privacy.py,sha256=Bt3gx2gyOhyDj7MirC_-hsgs3zjXD6zyUHWWsl1jyZ0,9933
|
|
9
|
+
agentrail_python/_processors.py,sha256=Gm0MTYOFzmzTGUV8yExYeDTtAP2oAARjyQdSZu7Epa8,10636
|
|
10
|
+
agentrail_python/_queue.py,sha256=Uy7HFBUSiEJLrZ8r-C73lNAKUcJVuYPDyaJKO311_x8,9059
|
|
11
|
+
agentrail_python/_runtime.py,sha256=uDZSG__fqFkT_sE24qIaAuCZ7_68-XY5VU9w72fIK_A,4141
|
|
12
|
+
agentrail_python/_suppression.py,sha256=v0yZngosDp-4Cg99H4DybJeu5UEcX4-aN1cezPVjd24,1045
|
|
13
|
+
agentrail_python/_text.py,sha256=VEPWuHFU9O0tovScuG9wLNFc1oRwcnk2Egx7diTfdVc,1681
|
|
14
|
+
agentrail_python/_version.py,sha256=tKaj8Z1fcaq9R1kXwA6xQEPixv4xDlcK0cMPFOeUw40,94
|
|
15
|
+
agentrail_python/asgi.py,sha256=nepsVFPFpx8ZRqs3EKhQbzFBZ5xm3D9KyyXgItdflwg,4114
|
|
16
|
+
agentrail_python/httpx.py,sha256=RezUnEf0xmRyq8WQ_IbYkgNhVaBE93Np-Vdz4Bp97IQ,5347
|
|
17
|
+
agentrail_python/logging.py,sha256=BWReSqXCSHwN01rU5uUWaFPMmNyReN3xSLLOMjCui9k,2623
|
|
18
|
+
agentrail_python/mcp.py,sha256=nv7wMTErEbkmMMqk5U4HJ3DoKe6DiCsBDFPcQ8YnL28,4582
|
|
19
|
+
agentrail_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
agentrail_python/requests.py,sha256=EODkzZB76PeaVM_-TllFaTCFNoKzJmBbJXelT_Dorfc,2274
|
|
21
|
+
agentrail_python/wsgi.py,sha256=hTLQPI_kZcEmoQqfF1bJu28OVJnQzo8valgPsNPcJek,5715
|
|
22
|
+
agentrail_python-0.1.0.dist-info/METADATA,sha256=n1O--Nl71r16-XNTDcP8lC9Slwo3A0OMZhRqehDaOF4,25995
|
|
23
|
+
agentrail_python-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
24
|
+
agentrail_python-0.1.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
25
|
+
agentrail_python-0.1.0.dist-info/RECORD,,
|