aforo-metering 1.0.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.
- aforo_metering-1.0.0/PKG-INFO +161 -0
- aforo_metering-1.0.0/README.md +127 -0
- aforo_metering-1.0.0/aforo/__init__.py +16 -0
- aforo_metering-1.0.0/aforo/buffer.py +62 -0
- aforo_metering-1.0.0/aforo/client.py +380 -0
- aforo_metering-1.0.0/aforo/idempotency.py +37 -0
- aforo_metering-1.0.0/aforo/limits.py +126 -0
- aforo_metering-1.0.0/aforo/middleware/__init__.py +0 -0
- aforo_metering-1.0.0/aforo/middleware/_common.py +80 -0
- aforo_metering-1.0.0/aforo/middleware/django.py +107 -0
- aforo_metering-1.0.0/aforo/middleware/fastapi.py +148 -0
- aforo_metering-1.0.0/aforo/middleware/flask.py +129 -0
- aforo_metering-1.0.0/aforo/path_normalizer.py +46 -0
- aforo_metering-1.0.0/aforo/transport.py +170 -0
- aforo_metering-1.0.0/aforo/types.py +146 -0
- aforo_metering-1.0.0/aforo_metering.egg-info/PKG-INFO +161 -0
- aforo_metering-1.0.0/aforo_metering.egg-info/SOURCES.txt +29 -0
- aforo_metering-1.0.0/aforo_metering.egg-info/dependency_links.txt +1 -0
- aforo_metering-1.0.0/aforo_metering.egg-info/requires.txt +18 -0
- aforo_metering-1.0.0/aforo_metering.egg-info/top_level.txt +1 -0
- aforo_metering-1.0.0/pyproject.toml +56 -0
- aforo_metering-1.0.0/setup.cfg +10 -0
- aforo_metering-1.0.0/setup.py +2 -0
- aforo_metering-1.0.0/tests/test_buffer.py +75 -0
- aforo_metering-1.0.0/tests/test_client.py +356 -0
- aforo_metering-1.0.0/tests/test_idempotency.py +44 -0
- aforo_metering-1.0.0/tests/test_limits.py +101 -0
- aforo_metering-1.0.0/tests/test_middleware.py +271 -0
- aforo_metering-1.0.0/tests/test_path_normalizer.py +29 -0
- aforo_metering-1.0.0/tests/test_transport.py +241 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aforo-metering
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Aforo usage metering SDK — track API usage events with batching, retry, and framework middleware
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/aforoai/aforo-metering-python
|
|
7
|
+
Project-URL: Repository, https://github.com/aforoai/aforo-metering-python
|
|
8
|
+
Keywords: aforo,metering,usage,billing,api,middleware
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: httpx>=0.25.0
|
|
21
|
+
Provides-Extra: fastapi
|
|
22
|
+
Requires-Dist: fastapi>=0.100.0; extra == "fastapi"
|
|
23
|
+
Requires-Dist: starlette>=0.27.0; extra == "fastapi"
|
|
24
|
+
Provides-Extra: django
|
|
25
|
+
Requires-Dist: django>=4.0; extra == "django"
|
|
26
|
+
Provides-Extra: flask
|
|
27
|
+
Requires-Dist: flask>=2.3.0; extra == "flask"
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
32
|
+
Requires-Dist: httpx>=0.25.0; extra == "dev"
|
|
33
|
+
Requires-Dist: respx>=0.21.0; extra == "dev"
|
|
34
|
+
|
|
35
|
+
# aforo-metering
|
|
36
|
+
|
|
37
|
+
Track API usage events from any Python service and let Aforo handle buffering, batching, and retry — plus drop-in middleware for FastAPI, Django, and Flask that meters every request without touching your handlers.
|
|
38
|
+
|
|
39
|
+
**Version:** 1.0.0 · Apache-2.0 · [Changelog](CHANGELOG.md) · [User guide](USER_GUIDE.md)
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
Intended public install:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install aforo-metering
|
|
47
|
+
# framework extras (pick what you use):
|
|
48
|
+
pip install "aforo-metering[fastapi]"
|
|
49
|
+
pip install "aforo-metering[django]"
|
|
50
|
+
pip install "aforo-metering[flask]"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Not yet on PyPI — install from source for now.** Straight from GitHub:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install "git+https://github.com/aforoai/SDKs.git#subdirectory=aforo-metering-sdks/python"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or clone the repo and install this package in editable mode, for local development:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git clone https://github.com/aforoai/SDKs.git
|
|
63
|
+
cd SDKs/aforo-metering-sdks/python # the folder holding pyproject.toml
|
|
64
|
+
pip install -e .
|
|
65
|
+
# with a framework extra:
|
|
66
|
+
pip install -e ".[fastapi]"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The only hard dependency is `httpx>=0.25`. Framework packages (`fastapi`/`starlette`, `django`, `flask`) are pulled in by the matching extra — they're not required for the bare client.
|
|
70
|
+
|
|
71
|
+
## Quickstart
|
|
72
|
+
|
|
73
|
+
Best when you control the call site and want to emit one event per billable action. `AforoClient` enqueues into a ring buffer and a background daemon thread flushes batches; you never block on the network.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import os
|
|
77
|
+
from aforo import AforoClient
|
|
78
|
+
|
|
79
|
+
client = AforoClient(api_key=os.environ["AFORO_API_KEY"], product_type="API")
|
|
80
|
+
|
|
81
|
+
client.track(
|
|
82
|
+
customer_id="cust_1", # who is billed
|
|
83
|
+
metric_name="api_calls", # what you're metering
|
|
84
|
+
quantity=1,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Per-event productType override + optional top-level ingest fields:
|
|
88
|
+
client.track(customer_id="cust_1", metric_name="agent_runs", product_type="AI_AGENT",
|
|
89
|
+
extra_fields={"agentId": "agent_7", "sessionId": "sess_42"})
|
|
90
|
+
|
|
91
|
+
# Force a synchronous flush when you need delivery confirmed:
|
|
92
|
+
result = client.flush() # FlushResult(sent=..., failed=...)
|
|
93
|
+
|
|
94
|
+
# Graceful shutdown drains the buffer. Also registered via atexit,
|
|
95
|
+
# so a clean interpreter exit flushes for you.
|
|
96
|
+
client.shutdown()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Events POST to `https://api.aforo.ai/v1/ingest/batch` with `X-API-Key: <api_key>`. The client appends `/v1/ingest/batch` to `base_url`, so set `base_url` to the host only.
|
|
100
|
+
|
|
101
|
+
> Tenant scope comes from the API key — there is no `tenant_id` argument on this SDK. `customer_id` is the entity you bill within that tenant. Never feed `customer_id` from a client-settable request header you don't trust.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
Pass these as keyword args to `AforoClient(...)`, or build an `AforoOptions` and pass `options=`.
|
|
106
|
+
|
|
107
|
+
| Option | Type | Default | What it does |
|
|
108
|
+
|---|---|---|---|
|
|
109
|
+
| `api_key` | `str` | — (required) | Aforo API key, sent as `X-API-Key` on every batch. |
|
|
110
|
+
| `base_url` | `str` | `https://api.aforo.ai` | Ingestor host. `/v1/ingest/batch` is appended automatically. |
|
|
111
|
+
| `product_type` | `str` | `"API"` | Top-level `productType` on every event (`API`, `AGENTIC_API`, `AI_AGENT`, `MCP_SERVER`, `GRPC_API`, `GRAPHQL_API`, `WEBSOCKET_API`, `MQTT_BROKER`); required by the production ingestor. Trimmed + upper-cased; override per event with `track(product_type=...)`. |
|
|
112
|
+
| `flush_count` | `int` | `50` | Buffered events that trigger a flush. Also the max batch size per request (clamped to 1..1000, the ingestor's limit). |
|
|
113
|
+
| `flush_interval` | `float` | `5.0` | Seconds between background timer flushes. |
|
|
114
|
+
| `max_queue_size` | `int` | `10000` | Ring-buffer capacity. On overflow the **oldest** event is dropped. |
|
|
115
|
+
| `max_retries` | `int` | `3` | Retries on 5xx / 408 / 429 with exponential backoff. |
|
|
116
|
+
| `retry_base_s` | `float` | `1.0` | Base delay for backoff (`retry_base_s * 2**attempt`). |
|
|
117
|
+
| `timeout` | `float` | `10.0` | Per-request HTTP timeout in seconds. |
|
|
118
|
+
| `shutdown_timeout` | `float` | `5.0` | Graceful-shutdown drain budget. |
|
|
119
|
+
| `heartbeat_interval` | `float` | `30.0` | Seconds between session heartbeats (see `start_session`). |
|
|
120
|
+
|
|
121
|
+
`track()` raises `ValueError` for a blank `customer_id` / `metric_name` or `quantity <= 0` — the ingestor rejects such an event, and one invalid event fails the whole batch.
|
|
122
|
+
|
|
123
|
+
Retry rules, fixed in the transport and not configurable beyond the values above: retry on **5xx, 408, 429**; honor `Retry-After` on 429; **never** retry other 4xx (the batch is dropped and counted as `failed`).
|
|
124
|
+
|
|
125
|
+
### Framework middleware
|
|
126
|
+
|
|
127
|
+
Each adapter constructs its own `AforoClient` and emits one event per request.
|
|
128
|
+
|
|
129
|
+
- **Metric:** `metric_name` — a fixed name or a callable; default `"api_calls"` (`aforo.DEFAULT_METRIC_NAME`). The metric must exist in your tenant's Aforo catalog: the ingestor rejects an unknown metric, and because it validates a batch as a whole, one rejected event fails every event in that batch.
|
|
130
|
+
- **Customer:** `customer_id` — a fixed id or a callable; default is the `X-Customer-Id` header (Django tries `request.user.id` first). The caller's `X-Api-Key` is never used — it is a secret, not a customer id. A request with no resolvable customer ID is **not** metered.
|
|
131
|
+
- **Product type:** `product_type` (Flask kwarg / `AFORO_PRODUCT_TYPE` config, Django `AFORO_PRODUCT_TYPE` setting, FastAPI kwarg) — default `"API"`.
|
|
132
|
+
- Every event carries top-level `endpointPath` (path without query string, max 512 chars), `httpMethod`, `statusCode` and `responseTimeMs`. A `quantity` resolving to `<= 0` is not metered.
|
|
133
|
+
- **CORS preflights** (`OPTIONS`) are never metered.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
# FastAPI / Starlette -- callables receive the ASGI scope
|
|
137
|
+
from aforo.middleware.fastapi import AforoMeteringMiddleware
|
|
138
|
+
app.add_middleware(AforoMeteringMiddleware, api_key=os.environ["AFORO_API_KEY"],
|
|
139
|
+
metric_name="api_calls", product_type="API")
|
|
140
|
+
|
|
141
|
+
# Flask -- metric_name(request, response), customer_id(request); or AFORO_METRIC_NAME / AFORO_CUSTOMER_ID config
|
|
142
|
+
from aforo.middleware.flask import AforoMetering
|
|
143
|
+
AforoMetering(app, api_key=os.environ["AFORO_API_KEY"], metric_name="api_calls",
|
|
144
|
+
customer_id=lambda req: req.headers.get("X-Customer-Id"))
|
|
145
|
+
|
|
146
|
+
# Django settings.py -- AFORO_METRIC_NAME: str or callable(request, response); AFORO_CUSTOMER_ID: str or callable(request)
|
|
147
|
+
MIDDLEWARE = [..., "aforo.middleware.django.AforoMeteringMiddleware"]
|
|
148
|
+
AFORO_API_KEY = os.environ["AFORO_API_KEY"]
|
|
149
|
+
AFORO_METRIC_NAME = "api_calls"
|
|
150
|
+
AFORO_PRODUCT_TYPE = "API"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`MiddlewareOptions` adds `product_type`, `metric_name`, `quantity`, `customer_id`, `metadata` (callables or constants), plus `exclude_paths` and `exclude_status_codes`. See the [user guide](USER_GUIDE.md#configuration-reference) for the full table.
|
|
154
|
+
|
|
155
|
+
## Walk me through it
|
|
156
|
+
|
|
157
|
+
The end-to-end path — install → configure → first metered event → confirm it landed in Aforo — is in **[USER_GUIDE.md](USER_GUIDE.md)**.
|
|
158
|
+
|
|
159
|
+
## What this doesn't cover
|
|
160
|
+
|
|
161
|
+
This SDK only **emits** usage events. It does not read entitlements, enforce quotas, or block requests — middleware always returns the original response, and metering failures are swallowed so they can't break your request path. Rate plans, pricing, and which `metric_name` values map to billable lines are configured in the Aforo console, not here. Broker- and gateway-side metering (Kong, EMQ X, etc.) live in their own plugins, not in this client.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# aforo-metering
|
|
2
|
+
|
|
3
|
+
Track API usage events from any Python service and let Aforo handle buffering, batching, and retry — plus drop-in middleware for FastAPI, Django, and Flask that meters every request without touching your handlers.
|
|
4
|
+
|
|
5
|
+
**Version:** 1.0.0 · Apache-2.0 · [Changelog](CHANGELOG.md) · [User guide](USER_GUIDE.md)
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Intended public install:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install aforo-metering
|
|
13
|
+
# framework extras (pick what you use):
|
|
14
|
+
pip install "aforo-metering[fastapi]"
|
|
15
|
+
pip install "aforo-metering[django]"
|
|
16
|
+
pip install "aforo-metering[flask]"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Not yet on PyPI — install from source for now.** Straight from GitHub:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install "git+https://github.com/aforoai/SDKs.git#subdirectory=aforo-metering-sdks/python"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or clone the repo and install this package in editable mode, for local development:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
git clone https://github.com/aforoai/SDKs.git
|
|
29
|
+
cd SDKs/aforo-metering-sdks/python # the folder holding pyproject.toml
|
|
30
|
+
pip install -e .
|
|
31
|
+
# with a framework extra:
|
|
32
|
+
pip install -e ".[fastapi]"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The only hard dependency is `httpx>=0.25`. Framework packages (`fastapi`/`starlette`, `django`, `flask`) are pulled in by the matching extra — they're not required for the bare client.
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
Best when you control the call site and want to emit one event per billable action. `AforoClient` enqueues into a ring buffer and a background daemon thread flushes batches; you never block on the network.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import os
|
|
43
|
+
from aforo import AforoClient
|
|
44
|
+
|
|
45
|
+
client = AforoClient(api_key=os.environ["AFORO_API_KEY"], product_type="API")
|
|
46
|
+
|
|
47
|
+
client.track(
|
|
48
|
+
customer_id="cust_1", # who is billed
|
|
49
|
+
metric_name="api_calls", # what you're metering
|
|
50
|
+
quantity=1,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Per-event productType override + optional top-level ingest fields:
|
|
54
|
+
client.track(customer_id="cust_1", metric_name="agent_runs", product_type="AI_AGENT",
|
|
55
|
+
extra_fields={"agentId": "agent_7", "sessionId": "sess_42"})
|
|
56
|
+
|
|
57
|
+
# Force a synchronous flush when you need delivery confirmed:
|
|
58
|
+
result = client.flush() # FlushResult(sent=..., failed=...)
|
|
59
|
+
|
|
60
|
+
# Graceful shutdown drains the buffer. Also registered via atexit,
|
|
61
|
+
# so a clean interpreter exit flushes for you.
|
|
62
|
+
client.shutdown()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Events POST to `https://api.aforo.ai/v1/ingest/batch` with `X-API-Key: <api_key>`. The client appends `/v1/ingest/batch` to `base_url`, so set `base_url` to the host only.
|
|
66
|
+
|
|
67
|
+
> Tenant scope comes from the API key — there is no `tenant_id` argument on this SDK. `customer_id` is the entity you bill within that tenant. Never feed `customer_id` from a client-settable request header you don't trust.
|
|
68
|
+
|
|
69
|
+
## Configuration
|
|
70
|
+
|
|
71
|
+
Pass these as keyword args to `AforoClient(...)`, or build an `AforoOptions` and pass `options=`.
|
|
72
|
+
|
|
73
|
+
| Option | Type | Default | What it does |
|
|
74
|
+
|---|---|---|---|
|
|
75
|
+
| `api_key` | `str` | — (required) | Aforo API key, sent as `X-API-Key` on every batch. |
|
|
76
|
+
| `base_url` | `str` | `https://api.aforo.ai` | Ingestor host. `/v1/ingest/batch` is appended automatically. |
|
|
77
|
+
| `product_type` | `str` | `"API"` | Top-level `productType` on every event (`API`, `AGENTIC_API`, `AI_AGENT`, `MCP_SERVER`, `GRPC_API`, `GRAPHQL_API`, `WEBSOCKET_API`, `MQTT_BROKER`); required by the production ingestor. Trimmed + upper-cased; override per event with `track(product_type=...)`. |
|
|
78
|
+
| `flush_count` | `int` | `50` | Buffered events that trigger a flush. Also the max batch size per request (clamped to 1..1000, the ingestor's limit). |
|
|
79
|
+
| `flush_interval` | `float` | `5.0` | Seconds between background timer flushes. |
|
|
80
|
+
| `max_queue_size` | `int` | `10000` | Ring-buffer capacity. On overflow the **oldest** event is dropped. |
|
|
81
|
+
| `max_retries` | `int` | `3` | Retries on 5xx / 408 / 429 with exponential backoff. |
|
|
82
|
+
| `retry_base_s` | `float` | `1.0` | Base delay for backoff (`retry_base_s * 2**attempt`). |
|
|
83
|
+
| `timeout` | `float` | `10.0` | Per-request HTTP timeout in seconds. |
|
|
84
|
+
| `shutdown_timeout` | `float` | `5.0` | Graceful-shutdown drain budget. |
|
|
85
|
+
| `heartbeat_interval` | `float` | `30.0` | Seconds between session heartbeats (see `start_session`). |
|
|
86
|
+
|
|
87
|
+
`track()` raises `ValueError` for a blank `customer_id` / `metric_name` or `quantity <= 0` — the ingestor rejects such an event, and one invalid event fails the whole batch.
|
|
88
|
+
|
|
89
|
+
Retry rules, fixed in the transport and not configurable beyond the values above: retry on **5xx, 408, 429**; honor `Retry-After` on 429; **never** retry other 4xx (the batch is dropped and counted as `failed`).
|
|
90
|
+
|
|
91
|
+
### Framework middleware
|
|
92
|
+
|
|
93
|
+
Each adapter constructs its own `AforoClient` and emits one event per request.
|
|
94
|
+
|
|
95
|
+
- **Metric:** `metric_name` — a fixed name or a callable; default `"api_calls"` (`aforo.DEFAULT_METRIC_NAME`). The metric must exist in your tenant's Aforo catalog: the ingestor rejects an unknown metric, and because it validates a batch as a whole, one rejected event fails every event in that batch.
|
|
96
|
+
- **Customer:** `customer_id` — a fixed id or a callable; default is the `X-Customer-Id` header (Django tries `request.user.id` first). The caller's `X-Api-Key` is never used — it is a secret, not a customer id. A request with no resolvable customer ID is **not** metered.
|
|
97
|
+
- **Product type:** `product_type` (Flask kwarg / `AFORO_PRODUCT_TYPE` config, Django `AFORO_PRODUCT_TYPE` setting, FastAPI kwarg) — default `"API"`.
|
|
98
|
+
- Every event carries top-level `endpointPath` (path without query string, max 512 chars), `httpMethod`, `statusCode` and `responseTimeMs`. A `quantity` resolving to `<= 0` is not metered.
|
|
99
|
+
- **CORS preflights** (`OPTIONS`) are never metered.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# FastAPI / Starlette -- callables receive the ASGI scope
|
|
103
|
+
from aforo.middleware.fastapi import AforoMeteringMiddleware
|
|
104
|
+
app.add_middleware(AforoMeteringMiddleware, api_key=os.environ["AFORO_API_KEY"],
|
|
105
|
+
metric_name="api_calls", product_type="API")
|
|
106
|
+
|
|
107
|
+
# Flask -- metric_name(request, response), customer_id(request); or AFORO_METRIC_NAME / AFORO_CUSTOMER_ID config
|
|
108
|
+
from aforo.middleware.flask import AforoMetering
|
|
109
|
+
AforoMetering(app, api_key=os.environ["AFORO_API_KEY"], metric_name="api_calls",
|
|
110
|
+
customer_id=lambda req: req.headers.get("X-Customer-Id"))
|
|
111
|
+
|
|
112
|
+
# Django settings.py -- AFORO_METRIC_NAME: str or callable(request, response); AFORO_CUSTOMER_ID: str or callable(request)
|
|
113
|
+
MIDDLEWARE = [..., "aforo.middleware.django.AforoMeteringMiddleware"]
|
|
114
|
+
AFORO_API_KEY = os.environ["AFORO_API_KEY"]
|
|
115
|
+
AFORO_METRIC_NAME = "api_calls"
|
|
116
|
+
AFORO_PRODUCT_TYPE = "API"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`MiddlewareOptions` adds `product_type`, `metric_name`, `quantity`, `customer_id`, `metadata` (callables or constants), plus `exclude_paths` and `exclude_status_codes`. See the [user guide](USER_GUIDE.md#configuration-reference) for the full table.
|
|
120
|
+
|
|
121
|
+
## Walk me through it
|
|
122
|
+
|
|
123
|
+
The end-to-end path — install → configure → first metered event → confirm it landed in Aforo — is in **[USER_GUIDE.md](USER_GUIDE.md)**.
|
|
124
|
+
|
|
125
|
+
## What this doesn't cover
|
|
126
|
+
|
|
127
|
+
This SDK only **emits** usage events. It does not read entitlements, enforce quotas, or block requests — middleware always returns the original response, and metering failures are swallowed so they can't break your request path. Rate plans, pricing, and which `metric_name` values map to billable lines are configured in the Aforo console, not here. Broker- and gateway-side metering (Kong, EMQ X, etc.) live in their own plugins, not in this client.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Aforo usage metering SDK — track API usage events with batching, retry, and framework middleware."""
|
|
2
|
+
|
|
3
|
+
from .client import AforoClient
|
|
4
|
+
from .middleware._common import DEFAULT_METRIC_NAME
|
|
5
|
+
from .types import AforoOptions, FlushResult, MiddlewareOptions, TrackEvent
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DEFAULT_METRIC_NAME",
|
|
9
|
+
"AforoClient",
|
|
10
|
+
"AforoOptions",
|
|
11
|
+
"FlushResult",
|
|
12
|
+
"MiddlewareOptions",
|
|
13
|
+
"TrackEvent",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Thread-safe bounded ring buffer for usage events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from collections import deque
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from .types import ResolvedEvent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RingBuffer:
|
|
13
|
+
"""Bounded, thread-safe ring buffer.
|
|
14
|
+
|
|
15
|
+
When full, the oldest event is dropped (FIFO overflow).
|
|
16
|
+
Uses ``collections.deque(maxlen=capacity)`` which handles
|
|
17
|
+
the ring semantics natively, plus a ``threading.Lock`` for
|
|
18
|
+
thread safety between the application thread and the flush thread.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, capacity: int = 10_000) -> None:
|
|
22
|
+
if capacity < 1:
|
|
23
|
+
raise ValueError("Buffer capacity must be >= 1")
|
|
24
|
+
self._capacity = capacity
|
|
25
|
+
self._buf: deque[ResolvedEvent] = deque(maxlen=capacity)
|
|
26
|
+
self._lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
def push(self, event: ResolvedEvent) -> bool:
|
|
29
|
+
"""Add an event. Returns True if added without overflow."""
|
|
30
|
+
with self._lock:
|
|
31
|
+
was_full = len(self._buf) == self._capacity
|
|
32
|
+
self._buf.append(event) # deque(maxlen) auto-drops oldest
|
|
33
|
+
return not was_full
|
|
34
|
+
|
|
35
|
+
def drain(self) -> list[ResolvedEvent]:
|
|
36
|
+
"""Remove and return all events."""
|
|
37
|
+
with self._lock:
|
|
38
|
+
items = list(self._buf)
|
|
39
|
+
self._buf.clear()
|
|
40
|
+
return items
|
|
41
|
+
|
|
42
|
+
def drain_up_to(self, max_count: int) -> list[ResolvedEvent]:
|
|
43
|
+
"""Remove and return up to ``max_count`` events from the front."""
|
|
44
|
+
with self._lock:
|
|
45
|
+
take = min(max_count, len(self._buf))
|
|
46
|
+
items = [self._buf.popleft() for _ in range(take)]
|
|
47
|
+
return items
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def size(self) -> int:
|
|
51
|
+
with self._lock:
|
|
52
|
+
return len(self._buf)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_empty(self) -> bool:
|
|
56
|
+
with self._lock:
|
|
57
|
+
return len(self._buf) == 0
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_full(self) -> bool:
|
|
61
|
+
with self._lock:
|
|
62
|
+
return len(self._buf) == self._capacity
|