embrasure-analytics 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ # Monorepo-wide ignore rules for local/generated artifacts.
2
+
3
+ # OS/editor
4
+ .DS_Store
5
+ Thumbs.db
6
+ .idea/
7
+ .vscode/
8
+
9
+ # Logs
10
+ *.log
11
+ npm-debug.log*
12
+ yarn-debug.log*
13
+ yarn-error.log*
14
+ pnpm-debug.log*
15
+
16
+ # Local migration/deploy artifacts
17
+ artifacts/
18
+ .docker-context/
19
+ tmp/cache-cost-scenarios/
20
+
21
+ # Root/local env files (keep examples committed)
22
+ .env
23
+ .env.*
24
+ .env_*
25
+ .braintrust.json
26
+ !.env.example
27
+ !.env.*.example
28
+
29
+ # Node / JS
30
+ **/node_modules/
31
+ **/.next/
32
+ **/out/
33
+ **/.turbo/
34
+ **/.cache/
35
+ **/*.tsbuildinfo
36
+
37
+ # Rust / Tauri
38
+ **/target/
39
+ **/src-tauri/gen/schemas/
40
+ apps/desktop/src-tauri/resources/runtime/*
41
+ !apps/desktop/src-tauri/resources/runtime/.gitkeep
42
+
43
+ # Python
44
+ **/__pycache__/
45
+ **/*.py[cod]
46
+ **/.venv/
47
+ **/.pytest_cache/
48
+ **/.mypy_cache/
49
+ **/.ruff_cache/
50
+ **/.coverage
51
+ **/.coverage.*
52
+ **/htmlcov/
53
+ **/dist/
54
+ **/build/
55
+ !apps/web/src/app/api/agent/context-graph/build/
56
+ !apps/web/src/app/api/agent/context-graph/build/route.ts
57
+ **/*.egg-info/
58
+
59
+ # Vercel
60
+ .vercel
61
+ .gstack/
62
+ .env*.local
63
+
64
+ # Terraform
65
+ **/.terraform/
66
+ **/.terraform.lock.hcl.local
67
+ **/terraform.tfstate
68
+ infra/aws/lambda/*.zip
69
+ **/terraform.tfstate.*
70
+ **/*.tfplan
71
+ **/*.tfvars
72
+ !**/*.tfvars.example
73
+
74
+ # Impeccable audit artifacts (local)
75
+ .impeccable/
76
+
77
+ # Playwright MCP session artifacts (local)
78
+ .playwright-mcp/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Embrasure
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.
@@ -0,0 +1,29 @@
1
+ Prior art: PostHog/posthog-python, revision f6c4b05865974d0945499c33d548d1099f9945bb (7.51.0).
2
+ https://github.com/PostHog/posthog-python/tree/f6c4b05865974d0945499c33d548d1099f9945bb
3
+
4
+ The background batching, bounded queue, immutable enqueue, retry, daemon lifecycle,
5
+ and weak fork/exit callback patterns are adapted from this implementation.
6
+ Embrasure owns its native event contract, receipt validation, and delivery limits.
7
+ The PostHog SDK is a test-only dependency; no PostHog services are contacted.
8
+
9
+ Copyright (c) 2023 PostHog (part of Hiberly Inc)
10
+
11
+ Copyright (c) 2013 Segment Inc. friends@segment.com
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
@@ -0,0 +1,282 @@
1
+ Metadata-Version: 2.5
2
+ Name: embrasure-analytics
3
+ Version: 0.1.0
4
+ Summary: Python event collection SDK for Embrasure
5
+ Project-URL: Homepage, https://embrasure.ai
6
+ Project-URL: Repository, https://github.com/EmbrasureAI/embrasure
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ License-File: NOTICE
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: httpx<1,>=0.27
12
+ Requires-Dist: typing-extensions<5,>=4.12
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Embrasure analytics for Python
16
+
17
+ A typed Python 3.10+ SDK for the same native collection API used by
18
+ `@embrasure/analytics` and `@embrasure/analytics/server`. One client can serve
19
+ concurrent requests. Identity and groups are explicit on each event.
20
+
21
+ Install the package:
22
+
23
+ ```sh
24
+ pip install embrasure-analytics==0.1.0
25
+ ```
26
+
27
+ From a source checkout:
28
+
29
+ ```sh
30
+ pip install ./packages/analytics-python
31
+ ```
32
+
33
+ ```python
34
+ import os
35
+ from embrasure_analytics import Client
36
+
37
+ analytics = Client(
38
+ os.environ["EMBRASURE_ANALYTICS_PROJECT_KEY"],
39
+ server_key=os.environ["EMBRASURE_ANALYTICS_SERVER_KEY"],
40
+ host="https://api.embrasure.ai/collect",
41
+ )
42
+
43
+ analytics.track(
44
+ "invoice_paid",
45
+ user_id="user_123",
46
+ id="invoice_paid:invoice_123", # Reuse only for retries of this action.
47
+ properties={"amount": 42, "currency": "USD"},
48
+ groups={"company": "company_456"},
49
+ )
50
+ analytics.identify(
51
+ "user_123",
52
+ anonymous_id="browser_anonymous_id", # Obtain from your browser integration.
53
+ traits={"plan": "pro"},
54
+ set_once={"signup_source": "pricing"},
55
+ )
56
+ analytics.group("company", "company_456", traits={"seats": 5})
57
+ result = analytics.shutdown(timeout=30)
58
+ # Inspect accepted, discarded, dropped, pending; acceptance precedes warehouse visibility.
59
+ ```
60
+
61
+ `track(event, **fields)`, `identify(user_id, **fields)`, and
62
+ `group(group_type, group_key, **fields)` return whether a message was queued.
63
+ This is not a delivery receipt. Invalid messages return `False` and emit a
64
+ count-only diagnostic; invalid client configuration raises `ValueError`.
65
+
66
+ Common fields are `id`, `timestamp`, `anonymous_id`, `session_id`, `properties`,
67
+ `context`, `traits`, `set_once`, and `groups`. Track and group also accept
68
+ `user_id`. IDs are nonblank strings of at most 200 characters. Track requires a
69
+ user or anonymous ID; identify requires a user ID. Group may omit actor identity.
70
+ Custom event names cannot start with `$`. Unknown fields are rejected.
71
+
72
+ IDs and timestamps default to a UUID and UTC time. Timestamps accept a
73
+ timezone-aware `datetime` or ISO 8601 string and normalize to UTC. Properties
74
+ must be JSON values with string object keys; NaN, infinity, objects, cycles,
75
+ and excessive nesting are rejected. Serialization snapshots input immediately;
76
+ mutating caller dictionaries afterwards cannot alter queued events or retries.
77
+ Identify and group emit immutable messages. They do not establish a current
78
+ user, session, or company on the client. Trait reconstruction and identity
79
+ resolution happen in the analytics processor.
80
+
81
+ ## Authentication and lifecycle
82
+
83
+ The public project key belongs in the body. A separate server credential goes
84
+ only in the Authorization header, just like the Node server SDK. `server_key`
85
+ is a required keyword: passing `None` explicitly chooses untrusted public-key
86
+ collection, matching browser/PostHog submissions. Never put a secret in the
87
+ project key. Analytics user IDs are attribution, not authenticated identity.
88
+
89
+ Keep one client per application process. A daemon worker starts on the first
90
+ valid event. Capture calls do no network I/O. HTTP connections are pooled;
91
+ redirects are not followed, and transport retries do not multiply SDK retries.
92
+
93
+ - `flush(timeout=10)` requests immediate delivery of work queued when called.
94
+ It respects backoff and returns pending work after a failed attempt, as Node
95
+ does. It can also return pending work if the total wait budget expires.
96
+ - `shutdown(timeout=30)` stops new events and drains through retries within one
97
+ total wait budget. At the deadline, unsent events are counted as dropped.
98
+ An already admitted HTTP request can finish afterwards and is reported pending.
99
+ No new requests are admitted after shutdown stops the worker.
100
+ - `destroy()` drops buffered work immediately, matching Node's destroy behavior.
101
+ - A context manager calls shutdown on exit. Normal interpreter exit attempts a
102
+ two-second drain. SIGKILL, crashes, and platform termination can still lose data.
103
+ - After a process fork, the child gets fresh locks, an empty queue, and a fresh
104
+ HTTP connection pool. Only the parent delivers its inherited buffered events.
105
+ Prefer creating clients in worker startup hooks with Gunicorn/preloaded apps.
106
+
107
+ `FlushResult` is an immutable object with `accepted`, `discarded`, `dropped`, and
108
+ `pending`. Method results report completions during that call; `statistics`
109
+ reports cumulative counts in this process, including earlier background sends
110
+ and local validation drops. Pending includes both queued and in-flight work.
111
+ Concurrent flush results may overlap; use statistics or diagnostics for metrics.
112
+
113
+ The optional `on_diagnostic(Diagnostic)` callback receives outcome, count, and a
114
+ fixed reason, never event bodies or secrets. Callback exceptions are isolated.
115
+ Callbacks may run on producer or worker threads, so keep them fast and
116
+ thread-safe. Lifecycle calls from a worker callback cannot wait on that worker;
117
+ shutdown/destroy in a callback drop remaining buffered work immediately.
118
+
119
+ ## Delivery contract
120
+
121
+ Defaults deliberately match the JS/Node SDK:
122
+
123
+ | Behavior | Limit/default |
124
+ | --- | --- |
125
+ | Queued plus in-flight events | 500 |
126
+ | Flush interval / threshold | 5 seconds / 50 events |
127
+ | Batch size including JSON envelope | 60 KiB, at most 50 events |
128
+ | Event size including collector defaults | 32 KiB UTF-8 |
129
+ | JSON depth / groups | 10 levels / 20 groups |
130
+ | HTTP I/O timeout | 5 seconds per network phase |
131
+ | Delivery attempts | 5 total per event |
132
+ | Retry backoff | Exponential, plus jitter; Retry-After up to 5 minutes |
133
+
134
+ `flush_at` (1–50), `max_queue_size` (1–500), `flush_interval` (at least 0.1s),
135
+ and `request_timeout` are configurable. Retries cover network errors, 408, 429,
136
+ 5xx, and ambiguous/malformed success receipts. Other HTTP errors drop the batch.
137
+ A valid success receipt must name `accepted` or `discarded` and acknowledge the
138
+ exact submitted count. An HTTP 2xx alone does not count as success.
139
+
140
+ The collector acknowledges native batches with HTTP 202 after sink acceptance;
141
+ that does not prove warehouse processing. Discard mode is reported separately.
142
+ The Flow writer deduplicates on workspace/project/environment/event ID, keeping
143
+ the first stored value. This is best-effort, memory-buffered analytics; use a
144
+ transactional outbox when an event must survive an application crash. There is
145
+ no automatic exception/PII capture, browser persistence, replay, or flag client.
146
+
147
+ ## FastAPI, Django, and jobs
148
+
149
+ For FastAPI, track normally on the request thread/event loop; offload blocking
150
+ shutdown during application teardown:
151
+
152
+ ```python
153
+ import asyncio
154
+ import os
155
+ from contextlib import asynccontextmanager
156
+ from fastapi import FastAPI
157
+ from embrasure_analytics import Client
158
+
159
+
160
+ @asynccontextmanager
161
+ async def lifespan(app):
162
+ analytics = Client(
163
+ os.environ["EMBRASURE_ANALYTICS_PROJECT_KEY"],
164
+ server_key=os.environ["EMBRASURE_ANALYTICS_SERVER_KEY"],
165
+ )
166
+ app.state.analytics = analytics
167
+ try:
168
+ yield
169
+ finally:
170
+ await asyncio.to_thread(analytics.shutdown, timeout=10)
171
+
172
+
173
+ app = FastAPI(lifespan=lifespan)
174
+ # In a route: request.app.state.analytics.track("report_created", user_id=user.id)
175
+ ```
176
+
177
+ For Django, initialize one client in each serving worker, then call
178
+ `analytics.track("report_created", user_id=str(request.user.pk))` after successful
179
+ work. Use the process server's worker-exit hook to call shutdown. Do not keep the
180
+ current request's user on a module global. No framework dependency is installed.
181
+
182
+ For scripts, jobs, and serverless invocations, use `with Client(...) as analytics`
183
+ or explicitly call `shutdown(timeout=...)` within the platform's remaining time.
184
+ Create a new client after shutdown; a shut-down client cannot be reused. The
185
+ context manager's default drain budget is 30 seconds.
186
+
187
+ ## PostHog prior art and compatibility
188
+
189
+ Reviewed and adapted from **PostHog Python 7.51.0**, source revision
190
+ [`f6c4b05865974d0945499c33d548d1099f9945bb`](https://github.com/PostHog/posthog-python/tree/f6c4b05865974d0945499c33d548d1099f9945bb):
191
+
192
+ - `posthog/client.py`: immutable enqueue, explicit lifecycle, weak fork/exit
193
+ hooks, and protection against waiting on the consumer from callbacks.
194
+ - `posthog/consumer.py`: daemon batching by count/time/bytes, bounded delivery,
195
+ retry classification, and releasing queue capacity after completion.
196
+ - `posthog/request.py` and `capture_v1.py`: pooled transport, Retry-After parsing,
197
+ and retry backoff. Embrasure handles retries at one layer and validates receipts.
198
+ - Upstream `test/test_consumer.py`: shutdown admission, queue acknowledgement,
199
+ payload sizing, callback failures, and retry cases informed our tests.
200
+
201
+ Upstream defaults allow much larger messages/queues and use a different wire
202
+ format. We retain Embrasure's lower JS/Node limits, native messages and explicit
203
+ receipt outcomes. HTTPX provides transport; PostHog is pinned as a **test-only**
204
+ dependency. No runtime fork, monkey patch, PostHog account, or PostHog service is
205
+ required. Upstream MIT attribution is included in [NOTICE](NOTICE).
206
+
207
+ The real pinned PostHog Python SDK is also tested against the collector for
208
+ capture, person set/set_once, groups, page events, and gzip. Tests found and
209
+ fixed its requirement for HTTP **200** and its separate `$set_once` event.
210
+ Native clients continue to use `/v1/batch` and HTTP 202. PostHog compatibility
211
+ covers capture, person set/set_once, groups, page events, and gzip with the v0
212
+ capture protocol. Feature flags, AI/error capture, alias, historical import, and
213
+ the v1 capture protocol are outside this integration.
214
+
215
+ ## Verification
216
+
217
+ The following commands and examples are for the Embrasure monorepo checkout;
218
+ the public package does not include the collector or internal test fixtures.
219
+
220
+ ```sh
221
+ uv sync --project apps/api --frozen --dev
222
+ uv sync --project packages/analytics-python --frozen
223
+ uv run --project packages/analytics-python --frozen pytest -q \
224
+ -c packages/analytics-python/pyproject.toml packages/analytics-python/tests
225
+ uv run --project packages/analytics-python --frozen ruff check packages/analytics-python
226
+ uv run --project packages/analytics-python --frozen mypy \
227
+ --config-file packages/analytics-python/pyproject.toml packages/analytics-python/src
228
+ uv build --project packages/analytics-python
229
+ ```
230
+
231
+ CI installs the built wheel and runs the tests on Python 3.10 and 3.14. The
232
+ shared `packages/analytics/tests/native-contract.json` fixture also runs through
233
+ the Node SDK and real collector. Unit tests exercise failures, concurrency,
234
+ limits, lifecycle deadlines and actual POSIX forks. Integration tests exercise
235
+ authenticated native HTTP, public attribution, rejected keys, real PostHog calls,
236
+ and interpreter-exit delivery. A recording sink does not prove warehouse storage.
237
+
238
+ For the full warehouse path, install the wheel in a clean environment and run
239
+ [examples/warehouse_smoke.py](examples/warehouse_smoke.py) with a configured
240
+ synthetic project and its **actual destination workspace**. The public
241
+ `pk_collection_smoke` key is operator-configured in the internal workspace; it
242
+ cannot select another workspace through SDK fields. The script checks live
243
+ collector health, sends 66 originals, two altered retries and four ordering
244
+ markers, and records the inputs, delivery counts and scoped verification SQL.
245
+ Run that SQL against the destination warehouse through an authorized product
246
+ session/MCP. Save its rows as JSON and verify them:
247
+
248
+ ```sh
249
+ python packages/analytics-python/examples/warehouse_smoke.py \
250
+ --workspace <configured-smoke-workspace> --manifest /tmp/python-sdk-smoke.json
251
+ python packages/analytics-python/examples/warehouse_smoke.py \
252
+ --manifest /tmp/python-sdk-smoke.json --verify-rows /tmp/warehouse-rows.json
253
+ ```
254
+
255
+ Verification requires exactly 70 unique persisted rows, original properties
256
+ surviving the changed retries, correct timestamps, identity, group/trait data,
257
+ trust metadata, and Unicode/nested JSON. Read the full result page; acceptance
258
+ alone cannot satisfy this check. No customer events or production configuration
259
+ are changed by the smoke script.
260
+
261
+ For a complete local run, start a dedicated copy of the private Flow repository's
262
+ `tests/local/compose.yaml` fixture with its Kafka profile and dynamic ports (see
263
+ that repository's `tests/local/README.md`). Then run the checked-in driver:
264
+
265
+ ```sh
266
+ uv run --with ./packages/analytics-python/dist/embrasure_analytics-0.1.0-py3-none-any.whl \
267
+ packages/analytics-python/examples/local_warehouse_smoke.py \
268
+ --binary /path/to/embrasure-flow-internal/target/release/embrasure-flow \
269
+ --kafka 127.0.0.1:<kafka-port> \
270
+ --catalog-uri http://127.0.0.1:<rest-port> \
271
+ --s3-endpoint http://127.0.0.1:<minio-port> \
272
+ --artifacts /tmp/python-sdk-warehouse-new-run
273
+ ```
274
+
275
+ This requires the API's local `.venv` and a new artifact directory. The driver
276
+ uses a unique topic, consumer group and namespace, copies the binary for a
277
+ stable run, uses the production analytics column mapping, and starts only its
278
+ own collector/writer processes. It verifies trusted native events, altered
279
+ retries, SIGKILL/restart with retained state, and five real PostHog messages via
280
+ stock DuckDB `iceberg_scan`. The caller owns starting/stopping the disposable
281
+ Docker services. Local and live end-to-end results are recorded in
282
+ [VALIDATION.md](VALIDATION.md).
@@ -0,0 +1,268 @@
1
+ # Embrasure analytics for Python
2
+
3
+ A typed Python 3.10+ SDK for the same native collection API used by
4
+ `@embrasure/analytics` and `@embrasure/analytics/server`. One client can serve
5
+ concurrent requests. Identity and groups are explicit on each event.
6
+
7
+ Install the package:
8
+
9
+ ```sh
10
+ pip install embrasure-analytics==0.1.0
11
+ ```
12
+
13
+ From a source checkout:
14
+
15
+ ```sh
16
+ pip install ./packages/analytics-python
17
+ ```
18
+
19
+ ```python
20
+ import os
21
+ from embrasure_analytics import Client
22
+
23
+ analytics = Client(
24
+ os.environ["EMBRASURE_ANALYTICS_PROJECT_KEY"],
25
+ server_key=os.environ["EMBRASURE_ANALYTICS_SERVER_KEY"],
26
+ host="https://api.embrasure.ai/collect",
27
+ )
28
+
29
+ analytics.track(
30
+ "invoice_paid",
31
+ user_id="user_123",
32
+ id="invoice_paid:invoice_123", # Reuse only for retries of this action.
33
+ properties={"amount": 42, "currency": "USD"},
34
+ groups={"company": "company_456"},
35
+ )
36
+ analytics.identify(
37
+ "user_123",
38
+ anonymous_id="browser_anonymous_id", # Obtain from your browser integration.
39
+ traits={"plan": "pro"},
40
+ set_once={"signup_source": "pricing"},
41
+ )
42
+ analytics.group("company", "company_456", traits={"seats": 5})
43
+ result = analytics.shutdown(timeout=30)
44
+ # Inspect accepted, discarded, dropped, pending; acceptance precedes warehouse visibility.
45
+ ```
46
+
47
+ `track(event, **fields)`, `identify(user_id, **fields)`, and
48
+ `group(group_type, group_key, **fields)` return whether a message was queued.
49
+ This is not a delivery receipt. Invalid messages return `False` and emit a
50
+ count-only diagnostic; invalid client configuration raises `ValueError`.
51
+
52
+ Common fields are `id`, `timestamp`, `anonymous_id`, `session_id`, `properties`,
53
+ `context`, `traits`, `set_once`, and `groups`. Track and group also accept
54
+ `user_id`. IDs are nonblank strings of at most 200 characters. Track requires a
55
+ user or anonymous ID; identify requires a user ID. Group may omit actor identity.
56
+ Custom event names cannot start with `$`. Unknown fields are rejected.
57
+
58
+ IDs and timestamps default to a UUID and UTC time. Timestamps accept a
59
+ timezone-aware `datetime` or ISO 8601 string and normalize to UTC. Properties
60
+ must be JSON values with string object keys; NaN, infinity, objects, cycles,
61
+ and excessive nesting are rejected. Serialization snapshots input immediately;
62
+ mutating caller dictionaries afterwards cannot alter queued events or retries.
63
+ Identify and group emit immutable messages. They do not establish a current
64
+ user, session, or company on the client. Trait reconstruction and identity
65
+ resolution happen in the analytics processor.
66
+
67
+ ## Authentication and lifecycle
68
+
69
+ The public project key belongs in the body. A separate server credential goes
70
+ only in the Authorization header, just like the Node server SDK. `server_key`
71
+ is a required keyword: passing `None` explicitly chooses untrusted public-key
72
+ collection, matching browser/PostHog submissions. Never put a secret in the
73
+ project key. Analytics user IDs are attribution, not authenticated identity.
74
+
75
+ Keep one client per application process. A daemon worker starts on the first
76
+ valid event. Capture calls do no network I/O. HTTP connections are pooled;
77
+ redirects are not followed, and transport retries do not multiply SDK retries.
78
+
79
+ - `flush(timeout=10)` requests immediate delivery of work queued when called.
80
+ It respects backoff and returns pending work after a failed attempt, as Node
81
+ does. It can also return pending work if the total wait budget expires.
82
+ - `shutdown(timeout=30)` stops new events and drains through retries within one
83
+ total wait budget. At the deadline, unsent events are counted as dropped.
84
+ An already admitted HTTP request can finish afterwards and is reported pending.
85
+ No new requests are admitted after shutdown stops the worker.
86
+ - `destroy()` drops buffered work immediately, matching Node's destroy behavior.
87
+ - A context manager calls shutdown on exit. Normal interpreter exit attempts a
88
+ two-second drain. SIGKILL, crashes, and platform termination can still lose data.
89
+ - After a process fork, the child gets fresh locks, an empty queue, and a fresh
90
+ HTTP connection pool. Only the parent delivers its inherited buffered events.
91
+ Prefer creating clients in worker startup hooks with Gunicorn/preloaded apps.
92
+
93
+ `FlushResult` is an immutable object with `accepted`, `discarded`, `dropped`, and
94
+ `pending`. Method results report completions during that call; `statistics`
95
+ reports cumulative counts in this process, including earlier background sends
96
+ and local validation drops. Pending includes both queued and in-flight work.
97
+ Concurrent flush results may overlap; use statistics or diagnostics for metrics.
98
+
99
+ The optional `on_diagnostic(Diagnostic)` callback receives outcome, count, and a
100
+ fixed reason, never event bodies or secrets. Callback exceptions are isolated.
101
+ Callbacks may run on producer or worker threads, so keep them fast and
102
+ thread-safe. Lifecycle calls from a worker callback cannot wait on that worker;
103
+ shutdown/destroy in a callback drop remaining buffered work immediately.
104
+
105
+ ## Delivery contract
106
+
107
+ Defaults deliberately match the JS/Node SDK:
108
+
109
+ | Behavior | Limit/default |
110
+ | --- | --- |
111
+ | Queued plus in-flight events | 500 |
112
+ | Flush interval / threshold | 5 seconds / 50 events |
113
+ | Batch size including JSON envelope | 60 KiB, at most 50 events |
114
+ | Event size including collector defaults | 32 KiB UTF-8 |
115
+ | JSON depth / groups | 10 levels / 20 groups |
116
+ | HTTP I/O timeout | 5 seconds per network phase |
117
+ | Delivery attempts | 5 total per event |
118
+ | Retry backoff | Exponential, plus jitter; Retry-After up to 5 minutes |
119
+
120
+ `flush_at` (1–50), `max_queue_size` (1–500), `flush_interval` (at least 0.1s),
121
+ and `request_timeout` are configurable. Retries cover network errors, 408, 429,
122
+ 5xx, and ambiguous/malformed success receipts. Other HTTP errors drop the batch.
123
+ A valid success receipt must name `accepted` or `discarded` and acknowledge the
124
+ exact submitted count. An HTTP 2xx alone does not count as success.
125
+
126
+ The collector acknowledges native batches with HTTP 202 after sink acceptance;
127
+ that does not prove warehouse processing. Discard mode is reported separately.
128
+ The Flow writer deduplicates on workspace/project/environment/event ID, keeping
129
+ the first stored value. This is best-effort, memory-buffered analytics; use a
130
+ transactional outbox when an event must survive an application crash. There is
131
+ no automatic exception/PII capture, browser persistence, replay, or flag client.
132
+
133
+ ## FastAPI, Django, and jobs
134
+
135
+ For FastAPI, track normally on the request thread/event loop; offload blocking
136
+ shutdown during application teardown:
137
+
138
+ ```python
139
+ import asyncio
140
+ import os
141
+ from contextlib import asynccontextmanager
142
+ from fastapi import FastAPI
143
+ from embrasure_analytics import Client
144
+
145
+
146
+ @asynccontextmanager
147
+ async def lifespan(app):
148
+ analytics = Client(
149
+ os.environ["EMBRASURE_ANALYTICS_PROJECT_KEY"],
150
+ server_key=os.environ["EMBRASURE_ANALYTICS_SERVER_KEY"],
151
+ )
152
+ app.state.analytics = analytics
153
+ try:
154
+ yield
155
+ finally:
156
+ await asyncio.to_thread(analytics.shutdown, timeout=10)
157
+
158
+
159
+ app = FastAPI(lifespan=lifespan)
160
+ # In a route: request.app.state.analytics.track("report_created", user_id=user.id)
161
+ ```
162
+
163
+ For Django, initialize one client in each serving worker, then call
164
+ `analytics.track("report_created", user_id=str(request.user.pk))` after successful
165
+ work. Use the process server's worker-exit hook to call shutdown. Do not keep the
166
+ current request's user on a module global. No framework dependency is installed.
167
+
168
+ For scripts, jobs, and serverless invocations, use `with Client(...) as analytics`
169
+ or explicitly call `shutdown(timeout=...)` within the platform's remaining time.
170
+ Create a new client after shutdown; a shut-down client cannot be reused. The
171
+ context manager's default drain budget is 30 seconds.
172
+
173
+ ## PostHog prior art and compatibility
174
+
175
+ Reviewed and adapted from **PostHog Python 7.51.0**, source revision
176
+ [`f6c4b05865974d0945499c33d548d1099f9945bb`](https://github.com/PostHog/posthog-python/tree/f6c4b05865974d0945499c33d548d1099f9945bb):
177
+
178
+ - `posthog/client.py`: immutable enqueue, explicit lifecycle, weak fork/exit
179
+ hooks, and protection against waiting on the consumer from callbacks.
180
+ - `posthog/consumer.py`: daemon batching by count/time/bytes, bounded delivery,
181
+ retry classification, and releasing queue capacity after completion.
182
+ - `posthog/request.py` and `capture_v1.py`: pooled transport, Retry-After parsing,
183
+ and retry backoff. Embrasure handles retries at one layer and validates receipts.
184
+ - Upstream `test/test_consumer.py`: shutdown admission, queue acknowledgement,
185
+ payload sizing, callback failures, and retry cases informed our tests.
186
+
187
+ Upstream defaults allow much larger messages/queues and use a different wire
188
+ format. We retain Embrasure's lower JS/Node limits, native messages and explicit
189
+ receipt outcomes. HTTPX provides transport; PostHog is pinned as a **test-only**
190
+ dependency. No runtime fork, monkey patch, PostHog account, or PostHog service is
191
+ required. Upstream MIT attribution is included in [NOTICE](NOTICE).
192
+
193
+ The real pinned PostHog Python SDK is also tested against the collector for
194
+ capture, person set/set_once, groups, page events, and gzip. Tests found and
195
+ fixed its requirement for HTTP **200** and its separate `$set_once` event.
196
+ Native clients continue to use `/v1/batch` and HTTP 202. PostHog compatibility
197
+ covers capture, person set/set_once, groups, page events, and gzip with the v0
198
+ capture protocol. Feature flags, AI/error capture, alias, historical import, and
199
+ the v1 capture protocol are outside this integration.
200
+
201
+ ## Verification
202
+
203
+ The following commands and examples are for the Embrasure monorepo checkout;
204
+ the public package does not include the collector or internal test fixtures.
205
+
206
+ ```sh
207
+ uv sync --project apps/api --frozen --dev
208
+ uv sync --project packages/analytics-python --frozen
209
+ uv run --project packages/analytics-python --frozen pytest -q \
210
+ -c packages/analytics-python/pyproject.toml packages/analytics-python/tests
211
+ uv run --project packages/analytics-python --frozen ruff check packages/analytics-python
212
+ uv run --project packages/analytics-python --frozen mypy \
213
+ --config-file packages/analytics-python/pyproject.toml packages/analytics-python/src
214
+ uv build --project packages/analytics-python
215
+ ```
216
+
217
+ CI installs the built wheel and runs the tests on Python 3.10 and 3.14. The
218
+ shared `packages/analytics/tests/native-contract.json` fixture also runs through
219
+ the Node SDK and real collector. Unit tests exercise failures, concurrency,
220
+ limits, lifecycle deadlines and actual POSIX forks. Integration tests exercise
221
+ authenticated native HTTP, public attribution, rejected keys, real PostHog calls,
222
+ and interpreter-exit delivery. A recording sink does not prove warehouse storage.
223
+
224
+ For the full warehouse path, install the wheel in a clean environment and run
225
+ [examples/warehouse_smoke.py](examples/warehouse_smoke.py) with a configured
226
+ synthetic project and its **actual destination workspace**. The public
227
+ `pk_collection_smoke` key is operator-configured in the internal workspace; it
228
+ cannot select another workspace through SDK fields. The script checks live
229
+ collector health, sends 66 originals, two altered retries and four ordering
230
+ markers, and records the inputs, delivery counts and scoped verification SQL.
231
+ Run that SQL against the destination warehouse through an authorized product
232
+ session/MCP. Save its rows as JSON and verify them:
233
+
234
+ ```sh
235
+ python packages/analytics-python/examples/warehouse_smoke.py \
236
+ --workspace <configured-smoke-workspace> --manifest /tmp/python-sdk-smoke.json
237
+ python packages/analytics-python/examples/warehouse_smoke.py \
238
+ --manifest /tmp/python-sdk-smoke.json --verify-rows /tmp/warehouse-rows.json
239
+ ```
240
+
241
+ Verification requires exactly 70 unique persisted rows, original properties
242
+ surviving the changed retries, correct timestamps, identity, group/trait data,
243
+ trust metadata, and Unicode/nested JSON. Read the full result page; acceptance
244
+ alone cannot satisfy this check. No customer events or production configuration
245
+ are changed by the smoke script.
246
+
247
+ For a complete local run, start a dedicated copy of the private Flow repository's
248
+ `tests/local/compose.yaml` fixture with its Kafka profile and dynamic ports (see
249
+ that repository's `tests/local/README.md`). Then run the checked-in driver:
250
+
251
+ ```sh
252
+ uv run --with ./packages/analytics-python/dist/embrasure_analytics-0.1.0-py3-none-any.whl \
253
+ packages/analytics-python/examples/local_warehouse_smoke.py \
254
+ --binary /path/to/embrasure-flow-internal/target/release/embrasure-flow \
255
+ --kafka 127.0.0.1:<kafka-port> \
256
+ --catalog-uri http://127.0.0.1:<rest-port> \
257
+ --s3-endpoint http://127.0.0.1:<minio-port> \
258
+ --artifacts /tmp/python-sdk-warehouse-new-run
259
+ ```
260
+
261
+ This requires the API's local `.venv` and a new artifact directory. The driver
262
+ uses a unique topic, consumer group and namespace, copies the binary for a
263
+ stable run, uses the production analytics column mapping, and starts only its
264
+ own collector/writer processes. It verifies trusted native events, altered
265
+ retries, SIGKILL/restart with retained state, and five real PostHog messages via
266
+ stock DuckDB `iceberg_scan`. The caller owns starting/stopping the disposable
267
+ Docker services. Local and live end-to-end results are recorded in
268
+ [VALIDATION.md](VALIDATION.md).
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "embrasure-analytics"
7
+ version = "0.1.0"
8
+ description = "Python event collection SDK for Embrasure"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE", "NOTICE"]
12
+ requires-python = ">=3.10"
13
+ dependencies = ["httpx>=0.27,<1", "typing-extensions>=4.12,<5"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://embrasure.ai"
17
+ Repository = "https://github.com/EmbrasureAI/embrasure"
18
+
19
+ [tool.hatch.build.targets.sdist]
20
+ include = ["/src", "/README.md", "/pyproject.toml", "/LICENSE", "/NOTICE"]
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/embrasure_analytics"]
24
+
25
+ [dependency-groups]
26
+ dev = ["pytest>=8,<10", "ruff>=0.12,<1", "mypy>=1.15,<2", "posthog==7.51.0"]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+ markers = ["integration: real SDKs through the standalone collection API"]
31
+
32
+ [tool.ruff]
33
+ target-version = "py310"
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E", "F", "I", "UP", "B"]
37
+
38
+ [tool.mypy]
39
+ python_version = "3.10"
40
+ strict = true
@@ -0,0 +1,7 @@
1
+ """Stateless server event collection for Embrasure."""
2
+
3
+ from .client import Client
4
+ from .types import Diagnostic, EventFields, FlushResult, Json, Properties
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["Client", "Diagnostic", "EventFields", "FlushResult", "Json", "Properties"]
@@ -0,0 +1,456 @@
1
+ """PostHog-style daemon delivery with Embrasure's native wire/receipt contract.
2
+
3
+ See NOTICE and README for the pinned upstream implementation and differences.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import atexit
9
+ import json
10
+ import math
11
+ import os
12
+ import random
13
+ import threading
14
+ import time
15
+ import weakref
16
+ from collections import deque
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass
19
+ from datetime import timezone
20
+ from email.utils import parsedate_to_datetime
21
+ from typing import Any
22
+ from urllib.parse import urlsplit
23
+
24
+ import httpx
25
+ from typing_extensions import Unpack
26
+
27
+ from .event import encode, serialize, valid_id
28
+ from .types import Diagnostic, EventFields, FlushResult, IdentifyFields
29
+
30
+
31
+ @dataclass
32
+ class _Item:
33
+ payload: bytes
34
+ sequence: int
35
+ queued_at: float
36
+ attempts: int = 0
37
+
38
+
39
+ def _seconds(value: float, minimum: float = 0) -> float:
40
+ if (
41
+ isinstance(value, bool)
42
+ or not isinstance(value, (int, float))
43
+ or not math.isfinite(value)
44
+ or value < minimum
45
+ ):
46
+ raise ValueError("Invalid timeout or interval")
47
+ return value
48
+
49
+
50
+ def _retry_delay(attempt: int, after: str | None) -> float:
51
+ delay = 0.0
52
+ if after:
53
+ try:
54
+ delay = float(after)
55
+ except ValueError:
56
+ try:
57
+ date = parsedate_to_datetime(after)
58
+ if date.tzinfo is None:
59
+ date = date.replace(tzinfo=timezone.utc)
60
+ delay = date.timestamp() - time.time()
61
+ except (ValueError, TypeError, OverflowError):
62
+ pass
63
+ if not math.isfinite(delay):
64
+ delay = 0.0
65
+ return float(
66
+ max(
67
+ min(300, max(0, delay)),
68
+ min(30, 2 ** (attempt - 1)) + random.random() * 0.25,
69
+ )
70
+ )
71
+
72
+
73
+ class Client:
74
+ """Thread-safe, stateless per event; tracking never waits for network I/O.
75
+
76
+ Supply the server key for trusted collection. Explicit ``server_key=None``
77
+ enables public-key collection, with the same untrusted attribution as the
78
+ browser/PostHog SDKs. Never substitute a secret for the public project key.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ project_key: str,
84
+ *,
85
+ server_key: str | None,
86
+ host: str = "https://api.embrasure.ai/collect",
87
+ flush_interval: float = 5,
88
+ flush_at: int = 50,
89
+ max_queue_size: int = 500,
90
+ request_timeout: float = 5,
91
+ on_diagnostic: Callable[[Diagnostic], None] | None = None,
92
+ ) -> None:
93
+ parsed = urlsplit(host)
94
+ if (
95
+ parsed.scheme not in {"http", "https"}
96
+ or not parsed.hostname
97
+ or parsed.username is not None
98
+ or parsed.password is not None
99
+ or parsed.query
100
+ or parsed.fragment
101
+ or any(c.isspace() or ord(c) < 32 for c in host)
102
+ ):
103
+ raise ValueError(
104
+ "Use an HTTP(S) host without credentials, query, or fragment"
105
+ )
106
+ try:
107
+ _ = parsed.port
108
+ except ValueError:
109
+ raise ValueError("Invalid host port") from None
110
+ if not valid_id(project_key):
111
+ raise ValueError("Invalid project key")
112
+ if server_key is not None and (
113
+ not isinstance(server_key, str)
114
+ or not server_key.strip()
115
+ or any(ord(c) < 33 or ord(c) > 126 for c in server_key)
116
+ ):
117
+ raise ValueError("Invalid server key")
118
+ if type(flush_at) is not int or not 1 <= flush_at <= 50:
119
+ raise ValueError("flush_at must be between 1 and 50")
120
+ if type(max_queue_size) is not int or not 1 <= max_queue_size <= 500:
121
+ raise ValueError("max_queue_size must be between 1 and 500")
122
+ self._endpoint = host.rstrip("/") + "/v1/batch"
123
+ self._prefix = b'{"project_key":' + encode(project_key) + b',"events":['
124
+ self._headers = {
125
+ "Content-Type": "application/json",
126
+ "User-Agent": "embrasure-analytics-python/0.1.0",
127
+ }
128
+ if server_key is not None:
129
+ self._headers["Authorization"] = "Bearer " + server_key
130
+ self._interval = _seconds(flush_interval, 0.1)
131
+ self._request_timeout = _seconds(request_timeout, 0.001)
132
+ self._flush_at = flush_at
133
+ self._capacity = max_queue_size
134
+ self._callback = on_diagnostic
135
+ self._accepting = True
136
+ self._reset_state()
137
+ # Weak callbacks avoid retaining unused clients for process lifetime.
138
+ ref = weakref.ref(self)
139
+
140
+ def on_exit() -> None:
141
+ client = ref()
142
+ if client is not None:
143
+ client.shutdown(timeout=2)
144
+
145
+ def after_fork() -> None:
146
+ client = ref()
147
+ if client is not None:
148
+ client._reset_state()
149
+
150
+ self._on_exit = on_exit
151
+ atexit.register(on_exit)
152
+ if hasattr(os, "register_at_fork"):
153
+ os.register_at_fork(after_in_child=after_fork)
154
+
155
+ def _reset_state(self) -> None:
156
+ # Never acquire inherited locks or reuse a parent's queue/HTTP pool.
157
+ # Only the parent may deliver its buffered events. Workers start lazily.
158
+ self._condition = threading.Condition()
159
+ self._queue: deque[_Item] = deque()
160
+ self._flight: list[_Item] = []
161
+ self._worker: threading.Thread | None = None
162
+ self._stopped = not self._accepting
163
+ self._sequence = self._force_until = 0
164
+ self._retry_at = 0.0
165
+ self._counts = [0, 0, 0] # accepted, discarded, dropped
166
+
167
+ def track(self, event: str, **fields: Unpack[EventFields]) -> bool:
168
+ """Queue a custom event; return False if invalid, closed, or full."""
169
+ return self._enqueue("track", {**fields, "event": event})
170
+
171
+ def identify(self, user_id: str, **fields: Unpack[IdentifyFields]) -> bool:
172
+ """Emit a user/anonymous link and trait patch; no shared current user."""
173
+ return self._enqueue("identify", {**fields, "user_id": user_id})
174
+
175
+ def group(
176
+ self, group_type: str, group_key: str, **fields: Unpack[EventFields]
177
+ ) -> bool:
178
+ """Emit group traits; pass groups explicitly on subsequent track calls."""
179
+ return self._enqueue(
180
+ "group", {**fields, "group_type": group_type, "group_key": group_key}
181
+ )
182
+
183
+ def _diagnostic(self, diagnostic: Diagnostic) -> None:
184
+ try:
185
+ if self._callback is not None:
186
+ self._callback(diagnostic)
187
+ except Exception:
188
+ # No payloads, credentials, exception strings, or host logging setup.
189
+ pass
190
+
191
+ def _enqueue(self, kind: str, fields: dict[str, Any]) -> bool:
192
+ with self._condition:
193
+ if not self._accepting:
194
+ return False
195
+ try:
196
+ payload = serialize(kind, fields)
197
+ except (ValueError, TypeError, OverflowError, RecursionError, UnicodeError):
198
+ with self._condition:
199
+ self._counts[2] += 1
200
+ self._diagnostic(Diagnostic("dropped", 1, "invalid_event"))
201
+ return False
202
+ diagnostic = None
203
+ with self._condition:
204
+ if not self._accepting:
205
+ return False
206
+ if len(self._queue) + len(self._flight) >= self._capacity:
207
+ self._counts[2] += 1
208
+ diagnostic = Diagnostic("dropped", 1, "queue_full")
209
+ else:
210
+ self._sequence += 1
211
+ self._queue.append(_Item(payload, self._sequence, time.monotonic()))
212
+ if self._worker is None:
213
+ self._worker = threading.Thread(
214
+ target=self._run, name="embrasure-analytics", daemon=True
215
+ )
216
+ try:
217
+ self._worker.start()
218
+ except RuntimeError:
219
+ # Telemetry must not break a request when the process
220
+ # cannot allocate another thread.
221
+ self._worker = None
222
+ self._accepting = False
223
+ self._stopped = True
224
+ self._counts[2] += len(self._queue)
225
+ diagnostic = Diagnostic(
226
+ "dropped", len(self._queue), "worker_error"
227
+ )
228
+ self._queue.clear()
229
+ self._condition.notify_all()
230
+ if diagnostic:
231
+ self._diagnostic(diagnostic)
232
+ return False
233
+ return True
234
+
235
+ @property
236
+ def statistics(self) -> FlushResult:
237
+ """Lifetime counts for this process, including background delivery."""
238
+ with self._condition:
239
+ return self._result([0, 0, 0])
240
+
241
+ def _result(self, before: list[int]) -> FlushResult:
242
+ return FlushResult(
243
+ accepted=self._counts[0] - before[0],
244
+ discarded=self._counts[1] - before[1],
245
+ dropped=self._counts[2] - before[2],
246
+ pending=len(self._queue) + len(self._flight),
247
+ )
248
+
249
+ def flush(self, timeout: float = 10) -> FlushResult:
250
+ """Attempt current events; respect retry backoff and return pending work.
251
+
252
+ As in Node, this does not wait through a scheduled retry. Use shutdown
253
+ for a bounded drain through retries before process exit.
254
+ """
255
+ deadline = time.monotonic() + _seconds(timeout)
256
+ with self._condition:
257
+ before = self._counts.copy()
258
+ target = self._sequence
259
+ self._force_until = max(self._force_until, target)
260
+ self._condition.notify_all()
261
+ while not self._stopped and threading.current_thread() is not self._worker:
262
+ pending = any(i.sequence <= target for i in self._flight) or (
263
+ self._queue and self._queue[0].sequence <= target
264
+ )
265
+ if not pending or (
266
+ not self._flight and self._retry_at > time.monotonic()
267
+ ):
268
+ break
269
+ remaining = deadline - time.monotonic()
270
+ if remaining <= 0:
271
+ break
272
+ self._condition.wait(remaining)
273
+ return self._result(before)
274
+
275
+ def shutdown(self, timeout: float = 30) -> FlushResult:
276
+ """Stop admitting events and drain through retries within a total budget.
277
+
278
+ On timeout, unsent events are dropped; an admitted HTTP request may
279
+ finish afterwards and is reported pending. Inspect the returned counts.
280
+ """
281
+ deadline = time.monotonic() + _seconds(timeout)
282
+ diagnostic = None
283
+ with self._condition:
284
+ before = self._counts.copy()
285
+ self._accepting = False
286
+ self._force_until = self._sequence
287
+ self._condition.notify_all()
288
+ # Lifecycle calls inside a diagnostic must never wait on the worker.
289
+ while (
290
+ (self._queue or self._flight)
291
+ and not self._stopped
292
+ and threading.current_thread() is not self._worker
293
+ ):
294
+ remaining = deadline - time.monotonic()
295
+ if remaining <= 0:
296
+ break
297
+ self._condition.wait(remaining)
298
+ self._stopped = True
299
+ if self._queue:
300
+ count = len(self._queue)
301
+ self._counts[2] += count
302
+ self._queue.clear()
303
+ diagnostic = Diagnostic("dropped", count, "shutdown_timeout")
304
+ self._condition.notify_all()
305
+ result = self._result(before)
306
+ if self._worker is not None and threading.current_thread() is not self._worker:
307
+ self._worker.join(max(0, deadline - time.monotonic()))
308
+ atexit.unregister(self._on_exit)
309
+ if diagnostic:
310
+ self._diagnostic(diagnostic)
311
+ return result
312
+
313
+ def destroy(self) -> FlushResult:
314
+ """Immediately stop and drop buffered work, matching Node destroy()."""
315
+ return self.shutdown(timeout=0)
316
+
317
+ def __enter__(self) -> Client:
318
+ return self
319
+
320
+ def __exit__(self, *_: object) -> None:
321
+ self.shutdown()
322
+
323
+ def _make_http(self) -> httpx.Client:
324
+ # The SDK owns retries; the HTTPX transport does not retry automatically.
325
+ return httpx.Client(
326
+ timeout=self._request_timeout,
327
+ follow_redirects=False,
328
+ limits=httpx.Limits(max_connections=1, max_keepalive_connections=1),
329
+ headers=self._headers,
330
+ )
331
+
332
+ def _send(
333
+ self, http: httpx.Client, body: bytes, count: int
334
+ ) -> tuple[str, str | None]:
335
+ try:
336
+ with http.stream("POST", self._endpoint, content=body) as response:
337
+ if 200 <= response.status_code < 300:
338
+ content = bytearray()
339
+ started = time.monotonic()
340
+ for chunk in response.iter_bytes(chunk_size=1024):
341
+ content.extend(chunk)
342
+ if (
343
+ len(content) > 16 * 1024
344
+ or time.monotonic() - started > self._request_timeout
345
+ ):
346
+ return "retry", None
347
+ receipt = json.loads(content)
348
+ if (
349
+ isinstance(receipt, dict)
350
+ and receipt.get("status") in ("accepted", "discarded")
351
+ and type(receipt.get("received")) is int
352
+ and receipt["received"] == count
353
+ ):
354
+ return str(receipt["status"]), None
355
+ return "retry", None
356
+ if response.status_code in (408, 429) or response.status_code >= 500:
357
+ return "retry", response.headers.get("Retry-After")
358
+ return "rejected", None
359
+ except (httpx.HTTPError, ValueError, UnicodeError):
360
+ # Ambiguous receipts retry the immutable original event IDs.
361
+ return "retry", None
362
+
363
+ def _run(self) -> None:
364
+ try:
365
+ with self._make_http() as http:
366
+ while True:
367
+ with self._condition:
368
+ while True:
369
+ if self._stopped or (
370
+ not self._accepting and not self._queue
371
+ ):
372
+ return
373
+ if not self._queue:
374
+ self._condition.wait()
375
+ continue
376
+ now = time.monotonic()
377
+ due = self._queue[0].queued_at + self._interval
378
+ if (
379
+ len(self._queue) >= self._flush_at
380
+ or self._queue[0].sequence <= self._force_until
381
+ ):
382
+ due = now
383
+ delay = max(due, self._retry_at) - now
384
+ if delay <= 0:
385
+ break
386
+ self._condition.wait(delay)
387
+ # Drain the current queue when the threshold/interval fires.
388
+ self._force_until = max(self._force_until, self._sequence)
389
+ size = len(self._prefix) + 2
390
+ batch: list[_Item] = []
391
+ while self._queue and len(batch) < self._flush_at:
392
+ extra = len(self._queue[0].payload) + bool(batch)
393
+ if batch and size + extra > 60 * 1024:
394
+ break
395
+ item = self._queue.popleft()
396
+ item.attempts += 1
397
+ batch.append(item)
398
+ size += extra
399
+ self._flight = batch
400
+ body = (
401
+ self._prefix + b",".join(item.payload for item in batch) + b"]}"
402
+ )
403
+ outcome, after = self._send(http, body, len(batch))
404
+ diagnostics = []
405
+ with self._condition:
406
+ self._flight = []
407
+ if outcome in ("accepted", "discarded"):
408
+ self._counts[0 if outcome == "accepted" else 1] += len(
409
+ batch
410
+ )
411
+ diagnostics.append(
412
+ Diagnostic(
413
+ "accepted"
414
+ if outcome == "accepted"
415
+ else "discarded",
416
+ len(batch),
417
+ )
418
+ )
419
+ else:
420
+ retry = (
421
+ [i for i in batch if i.attempts < 5]
422
+ if outcome == "retry" and not self._stopped
423
+ else []
424
+ )
425
+ dropped = len(batch) - len(retry)
426
+ if dropped:
427
+ self._counts[2] += dropped
428
+ reason = (
429
+ "retry_limit"
430
+ if outcome == "retry"
431
+ else "request_rejected"
432
+ )
433
+ diagnostics.append(
434
+ Diagnostic("dropped", dropped, reason)
435
+ )
436
+ if retry:
437
+ self._queue.extendleft(reversed(retry))
438
+ self._retry_at = time.monotonic() + _retry_delay(
439
+ retry[0].attempts, after
440
+ )
441
+ diagnostics.append(Diagnostic("retry", len(retry)))
442
+ self._condition.notify_all()
443
+ for diagnostic in diagnostics:
444
+ self._diagnostic(diagnostic)
445
+ except Exception:
446
+ # Unexpected worker/transport setup failures must be observable and
447
+ # must never leave flush/shutdown waiting on a dead consumer.
448
+ with self._condition:
449
+ count = len(self._queue) + len(self._flight)
450
+ self._counts[2] += count
451
+ self._queue.clear()
452
+ self._flight = []
453
+ self._accepting = False
454
+ self._stopped = True
455
+ self._condition.notify_all()
456
+ self._diagnostic(Diagnostic("dropped", count, "worker_error"))
@@ -0,0 +1,102 @@
1
+ """Match the native JS SDK and CollectionEvent validation before enqueueing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+ from uuid import uuid4
10
+
11
+
12
+ def valid_id(value: object) -> bool:
13
+ return isinstance(value, str) and bool(value.strip()) and len(value) <= 200
14
+
15
+
16
+ def encode(value: object) -> bytes:
17
+ return json.dumps(
18
+ value, ensure_ascii=False, allow_nan=False, separators=(",", ":")
19
+ ).encode("utf-8")
20
+
21
+
22
+ def validate_json(value: object, depth: int = 0) -> None:
23
+ if depth > 10:
24
+ raise ValueError("JSON exceeds 10 levels")
25
+ if value is None or type(value) in (bool, str, int):
26
+ return
27
+ if type(value) is float and math.isfinite(value):
28
+ return
29
+ if type(value) is list:
30
+ for item in value:
31
+ validate_json(item, depth + 1)
32
+ return
33
+ if type(value) is dict and all(type(key) is str for key in value):
34
+ for item in value.values():
35
+ validate_json(item, depth + 1)
36
+ return
37
+ raise ValueError("Expected JSON")
38
+
39
+
40
+ def serialize(kind: str, fields: dict[str, Any]) -> bytes:
41
+ # Include collector defaults when sizing, so a near-limit message isn't
42
+ # rejected after the server expands its optional fields.
43
+ event: dict[str, Any] = dict.fromkeys(
44
+ ("event", "user_id", "anonymous_id", "session_id", "group_type", "group_key")
45
+ )
46
+ event.update(
47
+ {key: {} for key in ("groups", "properties", "context", "traits", "set_once")}
48
+ )
49
+ allowed = set(event) | {"id", "timestamp"}
50
+ if fields.keys() - allowed:
51
+ raise ValueError("Unknown event fields")
52
+ event.update(fields)
53
+ event["type"] = kind
54
+ event["id"] = fields.get("id", str(uuid4()))
55
+ timestamp = fields.get("timestamp", datetime.now(timezone.utc))
56
+ if isinstance(timestamp, str):
57
+ timestamp = datetime.fromisoformat(
58
+ timestamp.removesuffix("Z") + ("+00:00" if timestamp.endswith("Z") else "")
59
+ )
60
+ if not isinstance(timestamp, datetime) or timestamp.utcoffset() is None:
61
+ raise ValueError("Expected timezone-aware timestamp")
62
+ event["timestamp"] = (
63
+ timestamp.astimezone(timezone.utc)
64
+ .isoformat(timespec="microseconds")
65
+ .replace("+00:00", "Z")
66
+ )
67
+ if not valid_id(event["id"]):
68
+ raise ValueError("Invalid event ID")
69
+ for key in ("user_id", "anonymous_id", "session_id", "group_type", "group_key"):
70
+ if event[key] is not None and not valid_id(event[key]):
71
+ raise ValueError("Invalid identity")
72
+ if kind == "track" and (
73
+ not valid_id(event["event"]) or event["event"].startswith("$")
74
+ ):
75
+ raise ValueError("Invalid event name")
76
+ if kind != "track" and event["event"] is not None:
77
+ raise ValueError("Unexpected event name")
78
+ if kind != "group" and not (event["user_id"] or event["anonymous_id"]):
79
+ raise ValueError("Missing identity")
80
+ if kind == "identify" and not event["user_id"]:
81
+ raise ValueError("Missing user ID")
82
+ if kind == "group" and not (event["group_type"] and event["group_key"]):
83
+ raise ValueError("Missing group")
84
+ if kind != "group" and (
85
+ event["group_type"] is not None or event["group_key"] is not None
86
+ ):
87
+ raise ValueError("Unexpected group fields")
88
+ for key in ("properties", "context", "traits", "set_once"):
89
+ if type(event[key]) is not dict:
90
+ raise ValueError("Expected object")
91
+ validate_json(event[key])
92
+ groups = event["groups"]
93
+ if (
94
+ type(groups) is not dict
95
+ or len(groups) > 20
96
+ or any(not valid_id(k) or not valid_id(v) for k, v in groups.items())
97
+ ):
98
+ raise ValueError("Invalid groups")
99
+ payload = encode(event)
100
+ if len(payload) > 32 * 1024:
101
+ raise ValueError("Event exceeds 32 KiB")
102
+ return payload
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Literal
6
+
7
+ from typing_extensions import TypedDict
8
+
9
+ Json = None | bool | int | float | str | list["Json"] | dict[str, "Json"]
10
+ Properties = dict[str, Json]
11
+
12
+
13
+ class IdentifyFields(TypedDict, total=False):
14
+ id: str
15
+ timestamp: str | datetime
16
+ anonymous_id: str
17
+ session_id: str
18
+ properties: Properties
19
+ context: Properties
20
+ traits: Properties
21
+ set_once: Properties
22
+ groups: dict[str, str]
23
+
24
+
25
+ class EventFields(IdentifyFields, total=False):
26
+ user_id: str
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class FlushResult:
31
+ """Outcomes during this call; pending includes queued and in-flight events."""
32
+
33
+ accepted: int = 0
34
+ discarded: int = 0
35
+ dropped: int = 0
36
+ pending: int = 0
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Diagnostic:
41
+ type: Literal["accepted", "discarded", "dropped", "retry"]
42
+ count: int
43
+ reason: str | None = None