beaconhq 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.
- beaconhq-0.1.0/.gitignore +14 -0
- beaconhq-0.1.0/LICENSE +21 -0
- beaconhq-0.1.0/PKG-INFO +269 -0
- beaconhq-0.1.0/README.md +229 -0
- beaconhq-0.1.0/pyproject.toml +80 -0
- beaconhq-0.1.0/src/beaconhq/__init__.py +61 -0
- beaconhq-0.1.0/src/beaconhq/client.py +323 -0
- beaconhq-0.1.0/src/beaconhq/event.py +74 -0
- beaconhq-0.1.0/src/beaconhq/integrations/__init__.py +15 -0
- beaconhq-0.1.0/src/beaconhq/integrations/_common.py +37 -0
- beaconhq-0.1.0/src/beaconhq/integrations/asgi.py +238 -0
- beaconhq-0.1.0/src/beaconhq/integrations/django.py +118 -0
- beaconhq-0.1.0/src/beaconhq/integrations/flask.py +138 -0
- beaconhq-0.1.0/src/beaconhq/py.typed +0 -0
- beaconhq-0.1.0/src/beaconhq/transport.py +118 -0
beaconhq-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Skyware LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
beaconhq-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: beaconhq
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Beacon Python client SDK — auto-capture HTTP request telemetry and ship it to the Beacon ingest API. FastAPI/Starlette, Flask, and Django adapters.
|
|
5
|
+
Project-URL: Homepage, https://beacon.skyware.dev
|
|
6
|
+
Project-URL: Documentation, https://beacon.skyware.dev/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/kb-gardner/HighHrothgar
|
|
8
|
+
Project-URL: Issues, https://github.com/kb-gardner/HighHrothgar/issues
|
|
9
|
+
Author: Skyware LLC
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api-monitoring,apm,beacon,django,fastapi,flask,monitoring,observability,starlette,telemetry
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Topic :: System :: Monitoring
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: django>=3.2; extra == 'dev'
|
|
29
|
+
Requires-Dist: flask>=2.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: httpx>=0.24; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: starlette>=0.27; extra == 'dev'
|
|
33
|
+
Provides-Extra: django
|
|
34
|
+
Requires-Dist: django>=3.2; extra == 'django'
|
|
35
|
+
Provides-Extra: fastapi
|
|
36
|
+
Requires-Dist: starlette>=0.27; extra == 'fastapi'
|
|
37
|
+
Provides-Extra: flask
|
|
38
|
+
Requires-Dist: flask>=2.0; extra == 'flask'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# beaconhq — Beacon Python SDK
|
|
42
|
+
|
|
43
|
+
Auto-capture HTTP request telemetry from your **FastAPI / Starlette**, **Flask**,
|
|
44
|
+
or **Django** app and ship it to [Beacon](https://beacon.skyware.dev). This is the
|
|
45
|
+
Python port of `@beaconhq/sdk` — same product, same ingest contract, idiomatic
|
|
46
|
+
Python.
|
|
47
|
+
|
|
48
|
+
> Full docs: <https://beacon.skyware.dev/docs>
|
|
49
|
+
|
|
50
|
+
- **Distribution name:** `beaconhq` (`pip install beaconhq`)
|
|
51
|
+
- **Import package:** `beaconhq` (`import beaconhq`)
|
|
52
|
+
- **Zero required dependencies** for the core — it ships telemetry using only the
|
|
53
|
+
Python standard library (`urllib`, `threading`, `json`, `atexit`). Framework
|
|
54
|
+
support is opt-in via extras and imported lazily, so `import beaconhq` works with
|
|
55
|
+
no web framework installed.
|
|
56
|
+
- **Python 3.9+.**
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install beaconhq # core only
|
|
62
|
+
pip install "beaconhq[fastapi]" # + Starlette/FastAPI adapter
|
|
63
|
+
pip install "beaconhq[flask]" # + Flask adapter
|
|
64
|
+
pip install "beaconhq[django]" # + Django adapter
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Quickstart
|
|
68
|
+
|
|
69
|
+
You only need a project ingest key — the client defaults to Beacon's hosted ingest
|
|
70
|
+
endpoint (`https://ingest.beacon.skyware.dev/v1/ingest`).
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from beaconhq import BeaconClient
|
|
74
|
+
|
|
75
|
+
beacon = BeaconClient(ingest_key="your-per-project-ingest-key")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Or configure from the environment (`BEACON_INGEST_KEY`, optional `BEACON_INGEST_URL`,
|
|
79
|
+
optional `BEACON_ENABLED`):
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from beaconhq import BeaconClient
|
|
83
|
+
beacon = BeaconClient.from_env()
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### FastAPI / Starlette
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from fastapi import FastAPI
|
|
90
|
+
from beaconhq import BeaconClient
|
|
91
|
+
from beaconhq.integrations.asgi import BeaconASGIMiddleware
|
|
92
|
+
|
|
93
|
+
beacon = BeaconClient(ingest_key=KEY)
|
|
94
|
+
|
|
95
|
+
app = FastAPI()
|
|
96
|
+
app.add_middleware(BeaconASGIMiddleware, client=beacon, consumer_header="x-api-key")
|
|
97
|
+
|
|
98
|
+
@app.get("/users/{user_id}")
|
|
99
|
+
def get_user(user_id: int):
|
|
100
|
+
return {"id": user_id}
|
|
101
|
+
# Captured route template: "/users/{user_id}" (not the concrete "/users/123")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Flask
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from flask import Flask
|
|
108
|
+
from beaconhq import BeaconClient
|
|
109
|
+
from beaconhq.integrations.flask import init_flask
|
|
110
|
+
|
|
111
|
+
beacon = BeaconClient(ingest_key=KEY)
|
|
112
|
+
|
|
113
|
+
app = Flask(__name__)
|
|
114
|
+
init_flask(app, beacon, consumer_header="X-API-Key")
|
|
115
|
+
|
|
116
|
+
@app.route("/users/<int:user_id>")
|
|
117
|
+
def get_user(user_id):
|
|
118
|
+
return {"id": user_id}
|
|
119
|
+
# Captured route template: "/users/<int:user_id>"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Django
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
# settings.py
|
|
126
|
+
from beaconhq import BeaconClient
|
|
127
|
+
|
|
128
|
+
BEACON_CLIENT = BeaconClient(ingest_key="your-per-project-ingest-key")
|
|
129
|
+
BEACON_CONSUMER_HEADER = "X-API-Key" # optional
|
|
130
|
+
|
|
131
|
+
MIDDLEWARE = [
|
|
132
|
+
"beaconhq.integrations.django.BeaconDjangoMiddleware", # put it near the top
|
|
133
|
+
# ... your other middleware ...
|
|
134
|
+
]
|
|
135
|
+
# Captured route template: "/users/<int:pk>" (from request.resolver_match.route)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
If `BEACON_CLIENT` is missing the middleware disables itself cleanly (Django
|
|
139
|
+
`MiddlewareNotUsed`) rather than erroring.
|
|
140
|
+
|
|
141
|
+
## Self-hosting / overriding the endpoint
|
|
142
|
+
|
|
143
|
+
Point at your own Beacon ingest by passing `ingest_url` (a full endpoint, or a base
|
|
144
|
+
URL to which `/v1/ingest` is appended):
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
beacon = BeaconClient(
|
|
148
|
+
ingest_key=KEY,
|
|
149
|
+
ingest_url="https://beacon.internal.example.com", # /v1/ingest appended if omitted
|
|
150
|
+
)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## How it works
|
|
154
|
+
|
|
155
|
+
`capture()` only appends to an in-memory buffer — it is **non-blocking** and adds
|
|
156
|
+
negligible latency to your request path. A background daemon thread flushes the
|
|
157
|
+
buffer to the ingest API every `flush_interval` seconds, or eagerly when it reaches
|
|
158
|
+
`batch_size`. The client **never raises into your app**: serialization and network
|
|
159
|
+
failures are caught, optionally reported via `on_error`, and the batch is re-queued
|
|
160
|
+
(network error / 5xx) or dropped (4xx). Remaining events are flushed on interpreter
|
|
161
|
+
exit via `atexit`.
|
|
162
|
+
|
|
163
|
+
## Identifying the consumer (the "who called me" hook)
|
|
164
|
+
|
|
165
|
+
`consumer` is the identity of the caller (an API-key id, user id, tenant…). The
|
|
166
|
+
simplest option is to read a header:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
app.add_middleware(BeaconASGIMiddleware, client=beacon, consumer_header="x-api-key")
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
For anything richer, pass a `consumer_resolver` — a callable that receives the
|
|
173
|
+
framework's request object and returns a string or `None`:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
def resolve_consumer(request) -> str | None:
|
|
177
|
+
# FastAPI/Starlette: `request` is the raw ASGI `scope` dict
|
|
178
|
+
# Flask: `request` is `flask.request`
|
|
179
|
+
# Django: `request` is the `HttpRequest`
|
|
180
|
+
return getattr(getattr(request, "state", None), "api_key_id", None)
|
|
181
|
+
|
|
182
|
+
app.add_middleware(
|
|
183
|
+
BeaconASGIMiddleware, client=beacon, consumer_resolver=resolve_consumer
|
|
184
|
+
)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
You can also set a default resolver on the client itself
|
|
188
|
+
(`BeaconClient(..., consumer_resolver=...)`) so every adapter uses it. Resolution
|
|
189
|
+
precedence: adapter `consumer_resolver` → client `consumer_resolver` → header value.
|
|
190
|
+
A resolver that raises is treated as "no consumer" — it never breaks the request.
|
|
191
|
+
|
|
192
|
+
## Configuration reference
|
|
193
|
+
|
|
194
|
+
`BeaconClient(...)` — option names are the snake_case translation of the JS
|
|
195
|
+
`BeaconClientOptions`; defaults match `@beaconhq/sdk`.
|
|
196
|
+
|
|
197
|
+
| Option | Type | Default | Description |
|
|
198
|
+
|---|---|---|---|
|
|
199
|
+
| `ingest_key` | `str` | — (required) | Per-project ingest key, sent as `Authorization: Bearer`. The only required argument. |
|
|
200
|
+
| `ingest_url` | `str` | `https://ingest.beacon.skyware.dev/v1/ingest` | Full endpoint or base URL (`/v1/ingest` appended). Override for self-hosted Beacon. |
|
|
201
|
+
| `batch_size` | `int` | `100` | Buffered events that trigger an eager flush. |
|
|
202
|
+
| `flush_interval` | `float` | `5.0` | Background flush cadence, **seconds** (JS uses 5000 ms). |
|
|
203
|
+
| `max_buffer_size` | `int` | `10000` | Memory cap; oldest events dropped past this. |
|
|
204
|
+
| `timeout` | `float` | `5.0` | Per-request HTTP timeout, seconds. |
|
|
205
|
+
| `enabled` | `bool` | `True` | When `False`, `capture()` is a no-op. |
|
|
206
|
+
| `dry_run` | `bool` | `False` | Buffer + "flush" to an in-memory sink; nothing leaves the process. |
|
|
207
|
+
| `debug` | `bool` | `False` | Attach a default `on_error` that logs at WARNING. |
|
|
208
|
+
| `on_error` | `Callable[[BaseException], None]` | no-op | Observability hook; never required, never re-raised. |
|
|
209
|
+
| `consumer_resolver` | `Callable[[req], str \| None]` | `None` | Default consumer hook for all adapters. |
|
|
210
|
+
| `transport` | `Transport` | HTTP | Inject a custom transport (tests). |
|
|
211
|
+
| `register_atexit` | `bool` | `True` | Flush remaining events on interpreter exit. |
|
|
212
|
+
|
|
213
|
+
Adapter options (`BeaconASGIMiddleware`, `init_flask`, and the Django settings
|
|
214
|
+
`BEACON_CONSUMER_HEADER` / `BEACON_CONSUMER_RESOLVER`): `consumer_header` and
|
|
215
|
+
`consumer_resolver`, as above.
|
|
216
|
+
|
|
217
|
+
Environment variables (read by `BeaconClient.from_env()`): `BEACON_INGEST_KEY`
|
|
218
|
+
(required), `BEACON_INGEST_URL` (optional — defaults to the hosted endpoint),
|
|
219
|
+
`BEACON_ENABLED` (`0`/`false`/`no` disables).
|
|
220
|
+
|
|
221
|
+
## Event payload
|
|
222
|
+
|
|
223
|
+
Each captured request becomes one event in the batched `POST /v1/ingest` body
|
|
224
|
+
(`{"events": [ ... ]}`). Field names and types match the ingest contract and the
|
|
225
|
+
server's validation exactly:
|
|
226
|
+
|
|
227
|
+
```json
|
|
228
|
+
{
|
|
229
|
+
"ts": "2026-06-03T12:00:00.000+00:00",
|
|
230
|
+
"method": "GET",
|
|
231
|
+
"route": "/users/{id}",
|
|
232
|
+
"path": "/users/123",
|
|
233
|
+
"status": 200,
|
|
234
|
+
"duration_ms": 42,
|
|
235
|
+
"consumer": "acme",
|
|
236
|
+
"error": null
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`route` is the low-cardinality **template** (resolved from the framework's
|
|
241
|
+
routing), which powers per-endpoint aggregation; `path` is the concrete request
|
|
242
|
+
path. `consumer` and `error` are always sent (as `null` when absent), matching the
|
|
243
|
+
JS SDK.
|
|
244
|
+
|
|
245
|
+
## Graceful shutdown
|
|
246
|
+
|
|
247
|
+
Buffered events are flushed automatically on interpreter exit. To flush explicitly
|
|
248
|
+
(e.g. in a worker's shutdown hook):
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
beacon.shutdown() # stops the timer and flushes remaining events; idempotent
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Development & tests
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
python -m venv .venv && source .venv/bin/activate
|
|
258
|
+
pip install -e ".[dev]"
|
|
259
|
+
pytest
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Core tests run against an in-memory mock transport (no network) and assert the
|
|
263
|
+
exact payload shape, batching, interval/shutdown flush, and the
|
|
264
|
+
never-raise/requeue behavior. Adapter smoke tests drive a real request through
|
|
265
|
+
each framework and skip if the framework isn't installed.
|
|
266
|
+
|
|
267
|
+
## License
|
|
268
|
+
|
|
269
|
+
MIT © Skyware LLC
|
beaconhq-0.1.0/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# beaconhq — Beacon Python SDK
|
|
2
|
+
|
|
3
|
+
Auto-capture HTTP request telemetry from your **FastAPI / Starlette**, **Flask**,
|
|
4
|
+
or **Django** app and ship it to [Beacon](https://beacon.skyware.dev). This is the
|
|
5
|
+
Python port of `@beaconhq/sdk` — same product, same ingest contract, idiomatic
|
|
6
|
+
Python.
|
|
7
|
+
|
|
8
|
+
> Full docs: <https://beacon.skyware.dev/docs>
|
|
9
|
+
|
|
10
|
+
- **Distribution name:** `beaconhq` (`pip install beaconhq`)
|
|
11
|
+
- **Import package:** `beaconhq` (`import beaconhq`)
|
|
12
|
+
- **Zero required dependencies** for the core — it ships telemetry using only the
|
|
13
|
+
Python standard library (`urllib`, `threading`, `json`, `atexit`). Framework
|
|
14
|
+
support is opt-in via extras and imported lazily, so `import beaconhq` works with
|
|
15
|
+
no web framework installed.
|
|
16
|
+
- **Python 3.9+.**
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install beaconhq # core only
|
|
22
|
+
pip install "beaconhq[fastapi]" # + Starlette/FastAPI adapter
|
|
23
|
+
pip install "beaconhq[flask]" # + Flask adapter
|
|
24
|
+
pip install "beaconhq[django]" # + Django adapter
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
You only need a project ingest key — the client defaults to Beacon's hosted ingest
|
|
30
|
+
endpoint (`https://ingest.beacon.skyware.dev/v1/ingest`).
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from beaconhq import BeaconClient
|
|
34
|
+
|
|
35
|
+
beacon = BeaconClient(ingest_key="your-per-project-ingest-key")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or configure from the environment (`BEACON_INGEST_KEY`, optional `BEACON_INGEST_URL`,
|
|
39
|
+
optional `BEACON_ENABLED`):
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from beaconhq import BeaconClient
|
|
43
|
+
beacon = BeaconClient.from_env()
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### FastAPI / Starlette
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from fastapi import FastAPI
|
|
50
|
+
from beaconhq import BeaconClient
|
|
51
|
+
from beaconhq.integrations.asgi import BeaconASGIMiddleware
|
|
52
|
+
|
|
53
|
+
beacon = BeaconClient(ingest_key=KEY)
|
|
54
|
+
|
|
55
|
+
app = FastAPI()
|
|
56
|
+
app.add_middleware(BeaconASGIMiddleware, client=beacon, consumer_header="x-api-key")
|
|
57
|
+
|
|
58
|
+
@app.get("/users/{user_id}")
|
|
59
|
+
def get_user(user_id: int):
|
|
60
|
+
return {"id": user_id}
|
|
61
|
+
# Captured route template: "/users/{user_id}" (not the concrete "/users/123")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Flask
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from flask import Flask
|
|
68
|
+
from beaconhq import BeaconClient
|
|
69
|
+
from beaconhq.integrations.flask import init_flask
|
|
70
|
+
|
|
71
|
+
beacon = BeaconClient(ingest_key=KEY)
|
|
72
|
+
|
|
73
|
+
app = Flask(__name__)
|
|
74
|
+
init_flask(app, beacon, consumer_header="X-API-Key")
|
|
75
|
+
|
|
76
|
+
@app.route("/users/<int:user_id>")
|
|
77
|
+
def get_user(user_id):
|
|
78
|
+
return {"id": user_id}
|
|
79
|
+
# Captured route template: "/users/<int:user_id>"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Django
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
# settings.py
|
|
86
|
+
from beaconhq import BeaconClient
|
|
87
|
+
|
|
88
|
+
BEACON_CLIENT = BeaconClient(ingest_key="your-per-project-ingest-key")
|
|
89
|
+
BEACON_CONSUMER_HEADER = "X-API-Key" # optional
|
|
90
|
+
|
|
91
|
+
MIDDLEWARE = [
|
|
92
|
+
"beaconhq.integrations.django.BeaconDjangoMiddleware", # put it near the top
|
|
93
|
+
# ... your other middleware ...
|
|
94
|
+
]
|
|
95
|
+
# Captured route template: "/users/<int:pk>" (from request.resolver_match.route)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
If `BEACON_CLIENT` is missing the middleware disables itself cleanly (Django
|
|
99
|
+
`MiddlewareNotUsed`) rather than erroring.
|
|
100
|
+
|
|
101
|
+
## Self-hosting / overriding the endpoint
|
|
102
|
+
|
|
103
|
+
Point at your own Beacon ingest by passing `ingest_url` (a full endpoint, or a base
|
|
104
|
+
URL to which `/v1/ingest` is appended):
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
beacon = BeaconClient(
|
|
108
|
+
ingest_key=KEY,
|
|
109
|
+
ingest_url="https://beacon.internal.example.com", # /v1/ingest appended if omitted
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## How it works
|
|
114
|
+
|
|
115
|
+
`capture()` only appends to an in-memory buffer — it is **non-blocking** and adds
|
|
116
|
+
negligible latency to your request path. A background daemon thread flushes the
|
|
117
|
+
buffer to the ingest API every `flush_interval` seconds, or eagerly when it reaches
|
|
118
|
+
`batch_size`. The client **never raises into your app**: serialization and network
|
|
119
|
+
failures are caught, optionally reported via `on_error`, and the batch is re-queued
|
|
120
|
+
(network error / 5xx) or dropped (4xx). Remaining events are flushed on interpreter
|
|
121
|
+
exit via `atexit`.
|
|
122
|
+
|
|
123
|
+
## Identifying the consumer (the "who called me" hook)
|
|
124
|
+
|
|
125
|
+
`consumer` is the identity of the caller (an API-key id, user id, tenant…). The
|
|
126
|
+
simplest option is to read a header:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
app.add_middleware(BeaconASGIMiddleware, client=beacon, consumer_header="x-api-key")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
For anything richer, pass a `consumer_resolver` — a callable that receives the
|
|
133
|
+
framework's request object and returns a string or `None`:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
def resolve_consumer(request) -> str | None:
|
|
137
|
+
# FastAPI/Starlette: `request` is the raw ASGI `scope` dict
|
|
138
|
+
# Flask: `request` is `flask.request`
|
|
139
|
+
# Django: `request` is the `HttpRequest`
|
|
140
|
+
return getattr(getattr(request, "state", None), "api_key_id", None)
|
|
141
|
+
|
|
142
|
+
app.add_middleware(
|
|
143
|
+
BeaconASGIMiddleware, client=beacon, consumer_resolver=resolve_consumer
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
You can also set a default resolver on the client itself
|
|
148
|
+
(`BeaconClient(..., consumer_resolver=...)`) so every adapter uses it. Resolution
|
|
149
|
+
precedence: adapter `consumer_resolver` → client `consumer_resolver` → header value.
|
|
150
|
+
A resolver that raises is treated as "no consumer" — it never breaks the request.
|
|
151
|
+
|
|
152
|
+
## Configuration reference
|
|
153
|
+
|
|
154
|
+
`BeaconClient(...)` — option names are the snake_case translation of the JS
|
|
155
|
+
`BeaconClientOptions`; defaults match `@beaconhq/sdk`.
|
|
156
|
+
|
|
157
|
+
| Option | Type | Default | Description |
|
|
158
|
+
|---|---|---|---|
|
|
159
|
+
| `ingest_key` | `str` | — (required) | Per-project ingest key, sent as `Authorization: Bearer`. The only required argument. |
|
|
160
|
+
| `ingest_url` | `str` | `https://ingest.beacon.skyware.dev/v1/ingest` | Full endpoint or base URL (`/v1/ingest` appended). Override for self-hosted Beacon. |
|
|
161
|
+
| `batch_size` | `int` | `100` | Buffered events that trigger an eager flush. |
|
|
162
|
+
| `flush_interval` | `float` | `5.0` | Background flush cadence, **seconds** (JS uses 5000 ms). |
|
|
163
|
+
| `max_buffer_size` | `int` | `10000` | Memory cap; oldest events dropped past this. |
|
|
164
|
+
| `timeout` | `float` | `5.0` | Per-request HTTP timeout, seconds. |
|
|
165
|
+
| `enabled` | `bool` | `True` | When `False`, `capture()` is a no-op. |
|
|
166
|
+
| `dry_run` | `bool` | `False` | Buffer + "flush" to an in-memory sink; nothing leaves the process. |
|
|
167
|
+
| `debug` | `bool` | `False` | Attach a default `on_error` that logs at WARNING. |
|
|
168
|
+
| `on_error` | `Callable[[BaseException], None]` | no-op | Observability hook; never required, never re-raised. |
|
|
169
|
+
| `consumer_resolver` | `Callable[[req], str \| None]` | `None` | Default consumer hook for all adapters. |
|
|
170
|
+
| `transport` | `Transport` | HTTP | Inject a custom transport (tests). |
|
|
171
|
+
| `register_atexit` | `bool` | `True` | Flush remaining events on interpreter exit. |
|
|
172
|
+
|
|
173
|
+
Adapter options (`BeaconASGIMiddleware`, `init_flask`, and the Django settings
|
|
174
|
+
`BEACON_CONSUMER_HEADER` / `BEACON_CONSUMER_RESOLVER`): `consumer_header` and
|
|
175
|
+
`consumer_resolver`, as above.
|
|
176
|
+
|
|
177
|
+
Environment variables (read by `BeaconClient.from_env()`): `BEACON_INGEST_KEY`
|
|
178
|
+
(required), `BEACON_INGEST_URL` (optional — defaults to the hosted endpoint),
|
|
179
|
+
`BEACON_ENABLED` (`0`/`false`/`no` disables).
|
|
180
|
+
|
|
181
|
+
## Event payload
|
|
182
|
+
|
|
183
|
+
Each captured request becomes one event in the batched `POST /v1/ingest` body
|
|
184
|
+
(`{"events": [ ... ]}`). Field names and types match the ingest contract and the
|
|
185
|
+
server's validation exactly:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{
|
|
189
|
+
"ts": "2026-06-03T12:00:00.000+00:00",
|
|
190
|
+
"method": "GET",
|
|
191
|
+
"route": "/users/{id}",
|
|
192
|
+
"path": "/users/123",
|
|
193
|
+
"status": 200,
|
|
194
|
+
"duration_ms": 42,
|
|
195
|
+
"consumer": "acme",
|
|
196
|
+
"error": null
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`route` is the low-cardinality **template** (resolved from the framework's
|
|
201
|
+
routing), which powers per-endpoint aggregation; `path` is the concrete request
|
|
202
|
+
path. `consumer` and `error` are always sent (as `null` when absent), matching the
|
|
203
|
+
JS SDK.
|
|
204
|
+
|
|
205
|
+
## Graceful shutdown
|
|
206
|
+
|
|
207
|
+
Buffered events are flushed automatically on interpreter exit. To flush explicitly
|
|
208
|
+
(e.g. in a worker's shutdown hook):
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
beacon.shutdown() # stops the timer and flushes remaining events; idempotent
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Development & tests
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
python -m venv .venv && source .venv/bin/activate
|
|
218
|
+
pip install -e ".[dev]"
|
|
219
|
+
pytest
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Core tests run against an in-memory mock transport (no network) and assert the
|
|
223
|
+
exact payload shape, batching, interval/shutdown flush, and the
|
|
224
|
+
never-raise/requeue behavior. Adapter smoke tests drive a real request through
|
|
225
|
+
each framework and skip if the framework isn't installed.
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT © Skyware LLC
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# PEP 621 packaging for the Beacon Python SDK.
|
|
2
|
+
#
|
|
3
|
+
# Distribution name: beaconhq (pip install beaconhq)
|
|
4
|
+
# Import package: beaconhq (import beaconhq)
|
|
5
|
+
#
|
|
6
|
+
# The core has ZERO required third-party dependencies — it ships telemetry to the
|
|
7
|
+
# Beacon ingest API using only the standard library (urllib, threading, json,
|
|
8
|
+
# atexit). Framework support is provided via optional extras that pull in the
|
|
9
|
+
# matching web framework and are imported lazily, so `import beaconhq` works with
|
|
10
|
+
# none of them installed.
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["hatchling"]
|
|
13
|
+
build-backend = "hatchling.build"
|
|
14
|
+
|
|
15
|
+
[project]
|
|
16
|
+
name = "beaconhq"
|
|
17
|
+
version = "0.1.0"
|
|
18
|
+
description = "Beacon Python client SDK — auto-capture HTTP request telemetry and ship it to the Beacon ingest API. FastAPI/Starlette, Flask, and Django adapters."
|
|
19
|
+
readme = "README.md"
|
|
20
|
+
requires-python = ">=3.9"
|
|
21
|
+
license = { text = "MIT" }
|
|
22
|
+
authors = [{ name = "Skyware LLC" }]
|
|
23
|
+
keywords = [
|
|
24
|
+
"beacon",
|
|
25
|
+
"observability",
|
|
26
|
+
"monitoring",
|
|
27
|
+
"telemetry",
|
|
28
|
+
"apm",
|
|
29
|
+
"api-monitoring",
|
|
30
|
+
"fastapi",
|
|
31
|
+
"starlette",
|
|
32
|
+
"flask",
|
|
33
|
+
"django",
|
|
34
|
+
]
|
|
35
|
+
classifiers = [
|
|
36
|
+
"Development Status :: 4 - Beta",
|
|
37
|
+
"Intended Audience :: Developers",
|
|
38
|
+
"License :: OSI Approved :: MIT License",
|
|
39
|
+
"Operating System :: OS Independent",
|
|
40
|
+
"Programming Language :: Python :: 3",
|
|
41
|
+
"Programming Language :: Python :: 3.9",
|
|
42
|
+
"Programming Language :: Python :: 3.10",
|
|
43
|
+
"Programming Language :: Python :: 3.11",
|
|
44
|
+
"Programming Language :: Python :: 3.12",
|
|
45
|
+
"Programming Language :: Python :: 3.13",
|
|
46
|
+
"Topic :: System :: Monitoring",
|
|
47
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
48
|
+
"Typing :: Typed",
|
|
49
|
+
]
|
|
50
|
+
dependencies = []
|
|
51
|
+
|
|
52
|
+
[project.urls]
|
|
53
|
+
Homepage = "https://beacon.skyware.dev"
|
|
54
|
+
Documentation = "https://beacon.skyware.dev/docs"
|
|
55
|
+
Repository = "https://github.com/kb-gardner/HighHrothgar"
|
|
56
|
+
Issues = "https://github.com/kb-gardner/HighHrothgar/issues"
|
|
57
|
+
|
|
58
|
+
[project.optional-dependencies]
|
|
59
|
+
# Framework adapters. Each imports its framework lazily; install only what you use.
|
|
60
|
+
fastapi = ["starlette>=0.27"]
|
|
61
|
+
flask = ["flask>=2.0"]
|
|
62
|
+
django = ["django>=3.2"]
|
|
63
|
+
# Dev/test toolchain.
|
|
64
|
+
dev = [
|
|
65
|
+
"pytest>=7.0",
|
|
66
|
+
"starlette>=0.27",
|
|
67
|
+
"httpx>=0.24",
|
|
68
|
+
"flask>=2.0",
|
|
69
|
+
"django>=3.2",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
[tool.hatch.build.targets.wheel]
|
|
73
|
+
packages = ["src/beaconhq"]
|
|
74
|
+
|
|
75
|
+
[tool.hatch.build.targets.sdist]
|
|
76
|
+
include = ["src/beaconhq", "README.md", "pyproject.toml", "LICENSE"]
|
|
77
|
+
|
|
78
|
+
[tool.pytest.ini_options]
|
|
79
|
+
testpaths = ["tests"]
|
|
80
|
+
addopts = "-q"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""beaconhq — the Beacon Python client SDK.
|
|
2
|
+
|
|
3
|
+
The core (:class:`BeaconClient`, :class:`BeaconEvent`) buffers and ships request
|
|
4
|
+
events to the Beacon ingest API and depends only on the standard library. The
|
|
5
|
+
framework adapters auto-capture method / route-template / status / latency from
|
|
6
|
+
FastAPI/Starlette, Flask, and Django, and live in :mod:`beaconhq.integrations`.
|
|
7
|
+
|
|
8
|
+
The raw HTTP contract in ``docs/ingest-contract.md`` is the language-agnostic
|
|
9
|
+
source of truth; this SDK is the convenience wrapper for Python services and is a
|
|
10
|
+
direct port of ``@beaconhq/sdk``.
|
|
11
|
+
|
|
12
|
+
Importing ``beaconhq`` never imports a web framework. The adapters are exposed
|
|
13
|
+
lazily here — accessing :data:`BeaconASGIMiddleware`, :data:`init_flask`, or
|
|
14
|
+
:data:`BeaconDjangoMiddleware` imports the relevant ``beaconhq.integrations.*``
|
|
15
|
+
module (which imports its framework) only on first use, so ``import beaconhq``
|
|
16
|
+
works with zero framework dependencies installed.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .client import DEFAULT_INGEST_URL, BeaconClient, utc_now_iso
|
|
24
|
+
from .event import BeaconEvent
|
|
25
|
+
from .transport import HttpTransport, MockTransport, SendResult, Transport
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"BeaconClient",
|
|
29
|
+
"BeaconEvent",
|
|
30
|
+
"Transport",
|
|
31
|
+
"HttpTransport",
|
|
32
|
+
"MockTransport",
|
|
33
|
+
"SendResult",
|
|
34
|
+
"utc_now_iso",
|
|
35
|
+
"DEFAULT_INGEST_URL",
|
|
36
|
+
# Lazily resolved framework adapters (see __getattr__).
|
|
37
|
+
"BeaconASGIMiddleware",
|
|
38
|
+
"init_flask",
|
|
39
|
+
"BeaconDjangoMiddleware",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
__version__ = "0.1.0"
|
|
43
|
+
|
|
44
|
+
# Map of lazily-exposed adapter names -> (submodule, attribute). Resolved on
|
|
45
|
+
# first attribute access so the core import stays framework-free.
|
|
46
|
+
_LAZY_ATTRS = {
|
|
47
|
+
"BeaconASGIMiddleware": (".integrations.asgi", "BeaconASGIMiddleware"),
|
|
48
|
+
"init_flask": (".integrations.flask", "init_flask"),
|
|
49
|
+
"BeaconDjangoMiddleware": (".integrations.django", "BeaconDjangoMiddleware"),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def __getattr__(name: str) -> Any:
|
|
54
|
+
"""PEP 562 module-level lazy attribute access for the framework adapters."""
|
|
55
|
+
target = _LAZY_ATTRS.get(name)
|
|
56
|
+
if target is None:
|
|
57
|
+
raise AttributeError(f"module 'beaconhq' has no attribute {name!r}")
|
|
58
|
+
from importlib import import_module
|
|
59
|
+
|
|
60
|
+
module = import_module(target[0], __name__)
|
|
61
|
+
return getattr(module, target[1])
|