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.
- embrasure_analytics-0.1.0/.gitignore +78 -0
- embrasure_analytics-0.1.0/LICENSE +21 -0
- embrasure_analytics-0.1.0/NOTICE +29 -0
- embrasure_analytics-0.1.0/PKG-INFO +282 -0
- embrasure_analytics-0.1.0/README.md +268 -0
- embrasure_analytics-0.1.0/pyproject.toml +40 -0
- embrasure_analytics-0.1.0/src/embrasure_analytics/__init__.py +7 -0
- embrasure_analytics-0.1.0/src/embrasure_analytics/client.py +456 -0
- embrasure_analytics-0.1.0/src/embrasure_analytics/event.py +102 -0
- embrasure_analytics-0.1.0/src/embrasure_analytics/py.typed +0 -0
- embrasure_analytics-0.1.0/src/embrasure_analytics/types.py +43 -0
|
@@ -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
|
|
File without changes
|
|
@@ -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
|