billdogeng 1.0.0b1__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.
- billdogeng-1.0.0b1/.gitignore +48 -0
- billdogeng-1.0.0b1/PKG-INFO +116 -0
- billdogeng-1.0.0b1/README.md +96 -0
- billdogeng-1.0.0b1/pyproject.toml +48 -0
- billdogeng-1.0.0b1/src/billdogeng/__init__.py +51 -0
- billdogeng-1.0.0b1/src/billdogeng/analytics.py +174 -0
- billdogeng-1.0.0b1/src/billdogeng/client.py +201 -0
- billdogeng-1.0.0b1/src/billdogeng/errors.py +28 -0
- billdogeng-1.0.0b1/src/billdogeng/flags.py +292 -0
- billdogeng-1.0.0b1/src/billdogeng/llm.py +91 -0
- billdogeng-1.0.0b1/src/billdogeng/messaging.py +65 -0
- billdogeng-1.0.0b1/src/billdogeng/murmur.py +83 -0
- billdogeng-1.0.0b1/src/billdogeng/py.typed +0 -0
- billdogeng-1.0.0b1/src/billdogeng/surveys.py +144 -0
- billdogeng-1.0.0b1/src/billdogeng/transport.py +152 -0
- billdogeng-1.0.0b1/src/billdogeng/types.py +143 -0
- billdogeng-1.0.0b1/tests/conftest.py +6 -0
- billdogeng-1.0.0b1/tests/stub_server.py +104 -0
- billdogeng-1.0.0b1/tests/test_analytics.py +124 -0
- billdogeng-1.0.0b1/tests/test_e2e_smoke.py +48 -0
- billdogeng-1.0.0b1/tests/test_flag_operators.py +61 -0
- billdogeng-1.0.0b1/tests/test_murmur.py +135 -0
- billdogeng-1.0.0b1/tests/test_retry.py +87 -0
- billdogeng-1.0.0b1/tests/test_surveys_messaging.py +190 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# billdogeng-* SDK fleet — build & dependency artifacts (do not commit)
|
|
2
|
+
|
|
3
|
+
# JS / TS (node, react, vue, next, svelte, angular, nuxt, astro, capacitor-eng)
|
|
4
|
+
**/node_modules/
|
|
5
|
+
**/dist/
|
|
6
|
+
**/.next/
|
|
7
|
+
**/.nuxt/
|
|
8
|
+
**/.svelte-kit/
|
|
9
|
+
**/.astro/
|
|
10
|
+
**/*.tsbuildinfo
|
|
11
|
+
|
|
12
|
+
# Python
|
|
13
|
+
**/__pycache__/
|
|
14
|
+
**/.venv/
|
|
15
|
+
**/*.egg-info/
|
|
16
|
+
**/.pytest_cache/
|
|
17
|
+
**/build/
|
|
18
|
+
**/.mypy_cache/
|
|
19
|
+
|
|
20
|
+
# PHP
|
|
21
|
+
php/vendor/
|
|
22
|
+
**/composer.lock
|
|
23
|
+
|
|
24
|
+
# Ruby
|
|
25
|
+
ruby/.bundle/
|
|
26
|
+
**/Gemfile.lock
|
|
27
|
+
**/*.gem
|
|
28
|
+
|
|
29
|
+
# Java / Gradle
|
|
30
|
+
**/.gradle/
|
|
31
|
+
java/build/
|
|
32
|
+
**/*.class
|
|
33
|
+
|
|
34
|
+
# Rust
|
|
35
|
+
rust/target/
|
|
36
|
+
**/Cargo.lock
|
|
37
|
+
|
|
38
|
+
# .NET
|
|
39
|
+
**/bin/
|
|
40
|
+
**/obj/
|
|
41
|
+
|
|
42
|
+
# Elixir
|
|
43
|
+
elixir/_build/
|
|
44
|
+
elixir/deps/
|
|
45
|
+
**/*.ez
|
|
46
|
+
|
|
47
|
+
# Go
|
|
48
|
+
**/*.test
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: billdogeng
|
|
3
|
+
Version: 1.0.0b1
|
|
4
|
+
Summary: Official BilldogEng server SDK for Python — Analytics, Feature Flags (remote + local eval), Surveys, Messaging, and LLM observability.
|
|
5
|
+
Project-URL: Homepage, https://billdog.io
|
|
6
|
+
Project-URL: Repository, https://github.com/billdog/billdogeng-python
|
|
7
|
+
Author: BillDog
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: analytics,billdog,billdogeng,feature-flags,llm,messaging,server-sdk,surveys
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# billdogeng (Python)
|
|
22
|
+
|
|
23
|
+
Official **BilldogEng** server SDK for Python — the engagement suite for
|
|
24
|
+
server-side use: **Analytics**, **Feature Flags** (remote + local evaluation),
|
|
25
|
+
**Surveys** (data API), **Messaging** dispatch, and **LLM** observability.
|
|
26
|
+
|
|
27
|
+
This is the Python port of the canonical Node reference SDK; the wire protocol
|
|
28
|
+
and method surface are identical across all `billdogeng-*` server SDKs.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install billdogeng
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Zero runtime dependencies (stdlib only). Requires Python 3.8+.
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from billdogeng import BilldogEng
|
|
42
|
+
|
|
43
|
+
bd = BilldogEng("bd_test_xxx", local_evaluation=True)
|
|
44
|
+
|
|
45
|
+
# Analytics (batched + background flush + gzip + retry)
|
|
46
|
+
bd.capture("user-123", "order_completed", {"revenue": 49.99}, {"company": "acme"})
|
|
47
|
+
bd.identify("user-123", {"email": "a@b.com", "plan": "pro"})
|
|
48
|
+
bd.group_identify("company", "acme", {"seats": 50})
|
|
49
|
+
bd.alias("user-123", "anon-abc")
|
|
50
|
+
|
|
51
|
+
# Feature flags (local evaluation = deterministic murmurhash3 bucketing)
|
|
52
|
+
if bd.is_feature_enabled("new_checkout", "user-123"):
|
|
53
|
+
...
|
|
54
|
+
variant = bd.get_feature_flag("pricing_test", "user-123")
|
|
55
|
+
payload = bd.get_feature_flag_payload("pricing_test", "user-123")
|
|
56
|
+
all_flags = bd.get_all_flags("user-123")
|
|
57
|
+
|
|
58
|
+
# Surveys (data API — no rendering)
|
|
59
|
+
surveys = bd.surveys.list("user-123")
|
|
60
|
+
config = bd.surveys.fetch(survey_id, "user-123")
|
|
61
|
+
started = bd.surveys.start(survey_id, {"customer_id": "user-123"})
|
|
62
|
+
bd.surveys.submit(survey_id, [{"question_id": q, "answer_number": 9}],
|
|
63
|
+
{"respondent_id": started["respondent_id"]})
|
|
64
|
+
|
|
65
|
+
# Messaging dispatch (Bearer JWT auth)
|
|
66
|
+
bd.messaging.dispatch(
|
|
67
|
+
project_id="…", channel="push",
|
|
68
|
+
content={"title": "Hi", "body": "There"},
|
|
69
|
+
access_token="<supabase-jwt>",
|
|
70
|
+
targeting={"type": "all"},
|
|
71
|
+
scheduling={"deliveryType": "immediate"},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# LLM observability
|
|
75
|
+
bd.llm.capture_trace(
|
|
76
|
+
trace_id="t-1", span_id="s-1", model="claude-opus-4-8",
|
|
77
|
+
input_text="…", output_text="…",
|
|
78
|
+
prompt_tokens=10, completion_tokens=5, duration_ms=123, cost_usd=0.002,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
bd.shutdown() # flushes remaining events + stops the background timer
|
|
82
|
+
|
|
83
|
+
# Or as a context manager:
|
|
84
|
+
with BilldogEng("bd_test_xxx") as bd:
|
|
85
|
+
bd.capture("user-123", "page_view")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
| Option | Default | Description |
|
|
91
|
+
| --- | --- | --- |
|
|
92
|
+
| `host` | `https://api.billdog.io/v1` | Base URL for all requests |
|
|
93
|
+
| `flush_at` | `20` | Batch size that triggers a flush |
|
|
94
|
+
| `flush_interval` | `10000` | Background flush cadence (ms) |
|
|
95
|
+
| `max_queue_size` | `1000` | Max queued events before oldest are dropped |
|
|
96
|
+
| `gzip` | `True` | Gzip request bodies large enough to benefit |
|
|
97
|
+
| `local_evaluation` | `False` | Enable local (server-side) flag evaluation |
|
|
98
|
+
| `request_timeout` | `10000` | Per-request timeout (ms) |
|
|
99
|
+
| `max_retries` | `3` | Retry attempts for 5xx / network errors |
|
|
100
|
+
| `group_type_index` | `None` | Stable group-type → positional index map |
|
|
101
|
+
|
|
102
|
+
Auth: `x-api-key: <apiKey>` on every request (`bd_test_*` sandbox /
|
|
103
|
+
`bd_live_*` live). Messaging dispatch authenticates with a Bearer JWT instead.
|
|
104
|
+
|
|
105
|
+
## Tests
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
109
|
+
pip install -e ".[dev]"
|
|
110
|
+
pytest -q
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The suite covers: batching (10 captures → 1 POST), identify/group/alias event
|
|
114
|
+
shapes, the 12 canonical murmurhash3 vectors + rollout/targeting/multivariate
|
|
115
|
+
evaluation, 503-then-200 retry with backoff (local stub server), and the
|
|
116
|
+
survey round-trip + messaging dispatch + LLM trace payload shapes.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# billdogeng (Python)
|
|
2
|
+
|
|
3
|
+
Official **BilldogEng** server SDK for Python — the engagement suite for
|
|
4
|
+
server-side use: **Analytics**, **Feature Flags** (remote + local evaluation),
|
|
5
|
+
**Surveys** (data API), **Messaging** dispatch, and **LLM** observability.
|
|
6
|
+
|
|
7
|
+
This is the Python port of the canonical Node reference SDK; the wire protocol
|
|
8
|
+
and method surface are identical across all `billdogeng-*` server SDKs.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install billdogeng
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Zero runtime dependencies (stdlib only). Requires Python 3.8+.
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from billdogeng import BilldogEng
|
|
22
|
+
|
|
23
|
+
bd = BilldogEng("bd_test_xxx", local_evaluation=True)
|
|
24
|
+
|
|
25
|
+
# Analytics (batched + background flush + gzip + retry)
|
|
26
|
+
bd.capture("user-123", "order_completed", {"revenue": 49.99}, {"company": "acme"})
|
|
27
|
+
bd.identify("user-123", {"email": "a@b.com", "plan": "pro"})
|
|
28
|
+
bd.group_identify("company", "acme", {"seats": 50})
|
|
29
|
+
bd.alias("user-123", "anon-abc")
|
|
30
|
+
|
|
31
|
+
# Feature flags (local evaluation = deterministic murmurhash3 bucketing)
|
|
32
|
+
if bd.is_feature_enabled("new_checkout", "user-123"):
|
|
33
|
+
...
|
|
34
|
+
variant = bd.get_feature_flag("pricing_test", "user-123")
|
|
35
|
+
payload = bd.get_feature_flag_payload("pricing_test", "user-123")
|
|
36
|
+
all_flags = bd.get_all_flags("user-123")
|
|
37
|
+
|
|
38
|
+
# Surveys (data API — no rendering)
|
|
39
|
+
surveys = bd.surveys.list("user-123")
|
|
40
|
+
config = bd.surveys.fetch(survey_id, "user-123")
|
|
41
|
+
started = bd.surveys.start(survey_id, {"customer_id": "user-123"})
|
|
42
|
+
bd.surveys.submit(survey_id, [{"question_id": q, "answer_number": 9}],
|
|
43
|
+
{"respondent_id": started["respondent_id"]})
|
|
44
|
+
|
|
45
|
+
# Messaging dispatch (Bearer JWT auth)
|
|
46
|
+
bd.messaging.dispatch(
|
|
47
|
+
project_id="…", channel="push",
|
|
48
|
+
content={"title": "Hi", "body": "There"},
|
|
49
|
+
access_token="<supabase-jwt>",
|
|
50
|
+
targeting={"type": "all"},
|
|
51
|
+
scheduling={"deliveryType": "immediate"},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# LLM observability
|
|
55
|
+
bd.llm.capture_trace(
|
|
56
|
+
trace_id="t-1", span_id="s-1", model="claude-opus-4-8",
|
|
57
|
+
input_text="…", output_text="…",
|
|
58
|
+
prompt_tokens=10, completion_tokens=5, duration_ms=123, cost_usd=0.002,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
bd.shutdown() # flushes remaining events + stops the background timer
|
|
62
|
+
|
|
63
|
+
# Or as a context manager:
|
|
64
|
+
with BilldogEng("bd_test_xxx") as bd:
|
|
65
|
+
bd.capture("user-123", "page_view")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
| Option | Default | Description |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| `host` | `https://api.billdog.io/v1` | Base URL for all requests |
|
|
73
|
+
| `flush_at` | `20` | Batch size that triggers a flush |
|
|
74
|
+
| `flush_interval` | `10000` | Background flush cadence (ms) |
|
|
75
|
+
| `max_queue_size` | `1000` | Max queued events before oldest are dropped |
|
|
76
|
+
| `gzip` | `True` | Gzip request bodies large enough to benefit |
|
|
77
|
+
| `local_evaluation` | `False` | Enable local (server-side) flag evaluation |
|
|
78
|
+
| `request_timeout` | `10000` | Per-request timeout (ms) |
|
|
79
|
+
| `max_retries` | `3` | Retry attempts for 5xx / network errors |
|
|
80
|
+
| `group_type_index` | `None` | Stable group-type → positional index map |
|
|
81
|
+
|
|
82
|
+
Auth: `x-api-key: <apiKey>` on every request (`bd_test_*` sandbox /
|
|
83
|
+
`bd_live_*` live). Messaging dispatch authenticates with a Bearer JWT instead.
|
|
84
|
+
|
|
85
|
+
## Tests
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
89
|
+
pip install -e ".[dev]"
|
|
90
|
+
pytest -q
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The suite covers: batching (10 captures → 1 POST), identify/group/alias event
|
|
94
|
+
shapes, the 12 canonical murmurhash3 vectors + rollout/targeting/multivariate
|
|
95
|
+
evaluation, 503-then-200 retry with backoff (local stub server), and the
|
|
96
|
+
survey round-trip + messaging dispatch + LLM trace payload shapes.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "billdogeng"
|
|
7
|
+
version = "1.0.0-beta.1"
|
|
8
|
+
description = "Official BilldogEng server SDK for Python — Analytics, Feature Flags (remote + local eval), Surveys, Messaging, and LLM observability."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "BillDog" }]
|
|
12
|
+
requires-python = ">=3.8"
|
|
13
|
+
keywords = [
|
|
14
|
+
"billdog",
|
|
15
|
+
"billdogeng",
|
|
16
|
+
"analytics",
|
|
17
|
+
"feature-flags",
|
|
18
|
+
"surveys",
|
|
19
|
+
"messaging",
|
|
20
|
+
"llm",
|
|
21
|
+
"server-sdk",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 5 - Production/Stable",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
29
|
+
"Topic :: Software Development :: Libraries",
|
|
30
|
+
]
|
|
31
|
+
dependencies = []
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://billdog.io"
|
|
35
|
+
Repository = "https://github.com/billdog/billdogeng-python"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = ["pytest>=7.0"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/billdogeng"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
include = ["src/billdogeng", "README.md", "tests"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
addopts = "-q"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""billdogeng -- Official BilldogEng server SDK for Python.
|
|
2
|
+
|
|
3
|
+
Engagement suite: Analytics + Feature Flags (remote + local eval) + Surveys +
|
|
4
|
+
Messaging + LLM observability.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .analytics import Analytics
|
|
10
|
+
from .client import BilldogEng
|
|
11
|
+
from .errors import BilldogEngError
|
|
12
|
+
from .flags import Flags
|
|
13
|
+
from .llm import Llm
|
|
14
|
+
from .messaging import Messaging
|
|
15
|
+
from .murmur import bucket_of, murmurhash3
|
|
16
|
+
from .surveys import Surveys
|
|
17
|
+
from .types import (
|
|
18
|
+
FeatureFlagDefinition,
|
|
19
|
+
FlagVariant,
|
|
20
|
+
Groups,
|
|
21
|
+
LlmTraceParams,
|
|
22
|
+
MessagingScheduling,
|
|
23
|
+
MessagingTargeting,
|
|
24
|
+
Properties,
|
|
25
|
+
SurveyContext,
|
|
26
|
+
TargetingRule,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__version__ = "1.0.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"BilldogEng",
|
|
33
|
+
"BilldogEngError",
|
|
34
|
+
"murmurhash3",
|
|
35
|
+
"bucket_of",
|
|
36
|
+
"Analytics",
|
|
37
|
+
"Flags",
|
|
38
|
+
"Surveys",
|
|
39
|
+
"Messaging",
|
|
40
|
+
"Llm",
|
|
41
|
+
"FeatureFlagDefinition",
|
|
42
|
+
"FlagVariant",
|
|
43
|
+
"TargetingRule",
|
|
44
|
+
"SurveyContext",
|
|
45
|
+
"MessagingTargeting",
|
|
46
|
+
"MessagingScheduling",
|
|
47
|
+
"LlmTraceParams",
|
|
48
|
+
"Properties",
|
|
49
|
+
"Groups",
|
|
50
|
+
"__version__",
|
|
51
|
+
]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Analytics client: capture/identify/groupIdentify/alias + batching.
|
|
2
|
+
|
|
3
|
+
Mirrors the Node reference ``analytics.ts``. Events accumulate in an in-memory
|
|
4
|
+
queue and are flushed as a single batched POST to ``/ingest-events`` when:
|
|
5
|
+
- the queue reaches ``flush_at``,
|
|
6
|
+
- the background timer fires every ``flush_interval``, or
|
|
7
|
+
- ``flush()`` / ``shutdown()`` is called.
|
|
8
|
+
|
|
9
|
+
A failed flush re-queues its batch (front of the queue) so events are never
|
|
10
|
+
lost on a single transient failure; the :class:`Transport` also retries with
|
|
11
|
+
backoff before a flush is considered failed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from .transport import Transport
|
|
21
|
+
from .types import Groups, Properties
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Analytics:
|
|
25
|
+
def __init__(self, transport: Transport, api_key: str, options: Dict[str, Any]) -> None:
|
|
26
|
+
self._transport = transport
|
|
27
|
+
self._api_key = api_key
|
|
28
|
+
self._opts = options
|
|
29
|
+
self._queue: List[Dict[str, Any]] = []
|
|
30
|
+
self._queue_lock = threading.Lock()
|
|
31
|
+
#: Serializes flushes so re-queued batches don't interleave.
|
|
32
|
+
self._flush_lock = threading.Lock()
|
|
33
|
+
self._timer: Optional[threading.Timer] = None
|
|
34
|
+
self._stopped = False
|
|
35
|
+
self._start_timer()
|
|
36
|
+
|
|
37
|
+
def _log(self, *args: Any) -> None:
|
|
38
|
+
if self._opts.get("enable_logging"):
|
|
39
|
+
print("[BilldogEng:analytics]", *args)
|
|
40
|
+
|
|
41
|
+
# --- Public API ---------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def capture(
|
|
44
|
+
self,
|
|
45
|
+
distinct_id: str,
|
|
46
|
+
event: str,
|
|
47
|
+
properties: Optional[Properties] = None,
|
|
48
|
+
groups: Optional[Groups] = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._enqueue(
|
|
51
|
+
distinct_id, event, self._with_groups(properties or {}, groups or {})
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def identify(self, distinct_id: str, properties: Optional[Properties] = None) -> None:
|
|
55
|
+
props = properties or {}
|
|
56
|
+
merged: Properties = {"$set": props}
|
|
57
|
+
merged.update(props)
|
|
58
|
+
self._enqueue(distinct_id, "$identify", merged)
|
|
59
|
+
|
|
60
|
+
def group_identify(
|
|
61
|
+
self, group_type: str, group_key: str, properties: Optional[Properties] = None
|
|
62
|
+
) -> None:
|
|
63
|
+
self._enqueue(
|
|
64
|
+
group_key,
|
|
65
|
+
"$groupidentify",
|
|
66
|
+
{
|
|
67
|
+
"$group_type": group_type,
|
|
68
|
+
"$group_key": group_key,
|
|
69
|
+
"$group_set": properties or {},
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def alias(self, distinct_id: str, alias: str) -> None:
|
|
74
|
+
self._enqueue(distinct_id, "$create_alias", {"alias": alias, "distinct_id": distinct_id})
|
|
75
|
+
|
|
76
|
+
def flush(self) -> None:
|
|
77
|
+
"""Force-flush the queue immediately.
|
|
78
|
+
|
|
79
|
+
Raises :class:`BilldogEngError` if the flush ultimately fails (after
|
|
80
|
+
retries); the batch is re-queued in that case.
|
|
81
|
+
"""
|
|
82
|
+
with self._flush_lock:
|
|
83
|
+
self._do_flush()
|
|
84
|
+
|
|
85
|
+
def stop_timer(self) -> None:
|
|
86
|
+
self._stopped = True
|
|
87
|
+
if self._timer is not None:
|
|
88
|
+
self._timer.cancel()
|
|
89
|
+
self._timer = None
|
|
90
|
+
|
|
91
|
+
def shutdown(self) -> None:
|
|
92
|
+
self.stop_timer()
|
|
93
|
+
self.flush()
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def queue_length(self) -> int:
|
|
97
|
+
with self._queue_lock:
|
|
98
|
+
return len(self._queue)
|
|
99
|
+
|
|
100
|
+
# --- Internals ----------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def _start_timer(self) -> None:
|
|
103
|
+
interval = self._opts.get("flush_interval", 10000) / 1000.0
|
|
104
|
+
self._timer = threading.Timer(interval, self._on_timer)
|
|
105
|
+
self._timer.daemon = True # don't keep the process alive
|
|
106
|
+
self._timer.start()
|
|
107
|
+
|
|
108
|
+
def _on_timer(self) -> None:
|
|
109
|
+
try:
|
|
110
|
+
with self._flush_lock:
|
|
111
|
+
self._do_flush()
|
|
112
|
+
except Exception as e: # noqa: BLE001 - background flush must not crash
|
|
113
|
+
self._log("background flush failed:", e)
|
|
114
|
+
finally:
|
|
115
|
+
if not self._stopped:
|
|
116
|
+
self._start_timer()
|
|
117
|
+
|
|
118
|
+
def _with_groups(self, properties: Properties, groups: Groups) -> Properties:
|
|
119
|
+
if not groups:
|
|
120
|
+
return dict(properties)
|
|
121
|
+
out: Properties = dict(properties)
|
|
122
|
+
out["$groups"] = dict(groups)
|
|
123
|
+
index_map = self._opts.get("group_type_index")
|
|
124
|
+
if index_map:
|
|
125
|
+
for gtype, gkey in groups.items():
|
|
126
|
+
idx = index_map.get(gtype)
|
|
127
|
+
if isinstance(idx, int) and 0 <= idx <= 4:
|
|
128
|
+
out[f"$group_{idx}"] = gkey
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
def _enqueue(self, distinct_id: str, event_name: str, properties: Properties) -> None:
|
|
132
|
+
wire_event = {
|
|
133
|
+
"event_name": event_name,
|
|
134
|
+
"event_timestamp": int(time.time() * 1000),
|
|
135
|
+
"properties": properties,
|
|
136
|
+
"user_id": distinct_id,
|
|
137
|
+
}
|
|
138
|
+
should_flush = False
|
|
139
|
+
with self._queue_lock:
|
|
140
|
+
self._queue.append(wire_event)
|
|
141
|
+
max_size = self._opts.get("max_queue_size", 1000)
|
|
142
|
+
if len(self._queue) > max_size:
|
|
143
|
+
dropped = len(self._queue) - max_size
|
|
144
|
+
del self._queue[0:dropped]
|
|
145
|
+
self._log(f"max queue size exceeded, dropped {dropped} oldest event(s)")
|
|
146
|
+
if len(self._queue) >= self._opts.get("flush_at", 20):
|
|
147
|
+
should_flush = True
|
|
148
|
+
|
|
149
|
+
if should_flush:
|
|
150
|
+
try:
|
|
151
|
+
self.flush()
|
|
152
|
+
except Exception as e: # noqa: BLE001 - auto-flush must not crash caller
|
|
153
|
+
self._log("auto-flush failed:", e)
|
|
154
|
+
|
|
155
|
+
def _do_flush(self) -> None:
|
|
156
|
+
with self._queue_lock:
|
|
157
|
+
if not self._queue:
|
|
158
|
+
return
|
|
159
|
+
batch = self._queue
|
|
160
|
+
self._queue = []
|
|
161
|
+
|
|
162
|
+
body = {"api_key": self._api_key, "events": batch}
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
self._transport.request(
|
|
166
|
+
"/ingest-events", body=body, headers={"x-api-key": self._api_key}
|
|
167
|
+
)
|
|
168
|
+
self._log(f"flushed {len(batch)} event(s)")
|
|
169
|
+
except Exception as err:
|
|
170
|
+
# Re-queue the batch at the front so it is retried on the next flush.
|
|
171
|
+
with self._queue_lock:
|
|
172
|
+
self._queue = batch + self._queue
|
|
173
|
+
self._log(f"flush failed, re-queued {len(batch)} event(s): {err}")
|
|
174
|
+
raise
|