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.
Files changed (30) hide show
  1. aforo_metering-1.0.0/PKG-INFO +161 -0
  2. aforo_metering-1.0.0/README.md +127 -0
  3. aforo_metering-1.0.0/aforo/__init__.py +16 -0
  4. aforo_metering-1.0.0/aforo/buffer.py +62 -0
  5. aforo_metering-1.0.0/aforo/client.py +380 -0
  6. aforo_metering-1.0.0/aforo/idempotency.py +37 -0
  7. aforo_metering-1.0.0/aforo/limits.py +126 -0
  8. aforo_metering-1.0.0/aforo/middleware/__init__.py +0 -0
  9. aforo_metering-1.0.0/aforo/middleware/_common.py +80 -0
  10. aforo_metering-1.0.0/aforo/middleware/django.py +107 -0
  11. aforo_metering-1.0.0/aforo/middleware/fastapi.py +148 -0
  12. aforo_metering-1.0.0/aforo/middleware/flask.py +129 -0
  13. aforo_metering-1.0.0/aforo/path_normalizer.py +46 -0
  14. aforo_metering-1.0.0/aforo/transport.py +170 -0
  15. aforo_metering-1.0.0/aforo/types.py +146 -0
  16. aforo_metering-1.0.0/aforo_metering.egg-info/PKG-INFO +161 -0
  17. aforo_metering-1.0.0/aforo_metering.egg-info/SOURCES.txt +29 -0
  18. aforo_metering-1.0.0/aforo_metering.egg-info/dependency_links.txt +1 -0
  19. aforo_metering-1.0.0/aforo_metering.egg-info/requires.txt +18 -0
  20. aforo_metering-1.0.0/aforo_metering.egg-info/top_level.txt +1 -0
  21. aforo_metering-1.0.0/pyproject.toml +56 -0
  22. aforo_metering-1.0.0/setup.cfg +10 -0
  23. aforo_metering-1.0.0/setup.py +2 -0
  24. aforo_metering-1.0.0/tests/test_buffer.py +75 -0
  25. aforo_metering-1.0.0/tests/test_client.py +356 -0
  26. aforo_metering-1.0.0/tests/test_idempotency.py +44 -0
  27. aforo_metering-1.0.0/tests/test_limits.py +101 -0
  28. aforo_metering-1.0.0/tests/test_middleware.py +271 -0
  29. aforo_metering-1.0.0/tests/test_path_normalizer.py +29 -0
  30. 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