growth-loop-sdk 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.
- growth_loop_sdk-0.1.0/.gitignore +56 -0
- growth_loop_sdk-0.1.0/LICENSE +21 -0
- growth_loop_sdk-0.1.0/PKG-INFO +218 -0
- growth_loop_sdk-0.1.0/README.md +188 -0
- growth_loop_sdk-0.1.0/pyproject.toml +67 -0
- growth_loop_sdk-0.1.0/src/growth/__init__.py +18 -0
- growth_loop_sdk-0.1.0/src/growth/client.py +165 -0
- growth_loop_sdk-0.1.0/src/growth/decorators.py +163 -0
- growth_loop_sdk-0.1.0/src/growth/py.typed +0 -0
- growth_loop_sdk-0.1.0/src/growth/types.py +57 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnpm-store/
|
|
4
|
+
|
|
5
|
+
# Build outputs
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.next/
|
|
9
|
+
.turbo/
|
|
10
|
+
out/
|
|
11
|
+
*.tsbuildinfo
|
|
12
|
+
|
|
13
|
+
# Python
|
|
14
|
+
__pycache__/
|
|
15
|
+
*.py[cod]
|
|
16
|
+
*$py.class
|
|
17
|
+
.venv/
|
|
18
|
+
venv/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.pytest_cache/
|
|
21
|
+
.ruff_cache/
|
|
22
|
+
.mypy_cache/
|
|
23
|
+
|
|
24
|
+
# Env / secrets
|
|
25
|
+
.env
|
|
26
|
+
.env.local
|
|
27
|
+
.env.*.local
|
|
28
|
+
.env.development
|
|
29
|
+
.env.production
|
|
30
|
+
!.env.example
|
|
31
|
+
|
|
32
|
+
# Editors
|
|
33
|
+
.vscode/
|
|
34
|
+
.idea/
|
|
35
|
+
*.swp
|
|
36
|
+
*.swo
|
|
37
|
+
|
|
38
|
+
# OS
|
|
39
|
+
.DS_Store
|
|
40
|
+
Thumbs.db
|
|
41
|
+
|
|
42
|
+
# Logs
|
|
43
|
+
*.log
|
|
44
|
+
npm-debug.log*
|
|
45
|
+
pnpm-debug.log*
|
|
46
|
+
yarn-debug.log*
|
|
47
|
+
|
|
48
|
+
# Coverage
|
|
49
|
+
coverage/
|
|
50
|
+
.nyc_output/
|
|
51
|
+
|
|
52
|
+
# Archives — never commit zipped exports of repo content
|
|
53
|
+
*.zip
|
|
54
|
+
*.tar
|
|
55
|
+
*.tar.gz
|
|
56
|
+
*.tgz
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 growth-loop.dev
|
|
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,218 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: growth-loop-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Growth Python SDK — drop-in product analytics for vibe-coded SaaS. Pairs with the Growth MCP server to give Claude Code an AI growth engineer for your Python app.
|
|
5
|
+
Project-URL: Homepage, https://growth-loop.dev
|
|
6
|
+
Project-URL: Repository, https://gitlab.com/AKnyaZP/metrics
|
|
7
|
+
Project-URL: Issues, https://gitlab.com/AKnyaZP/metrics/-/issues
|
|
8
|
+
Author: growth-loop.dev
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: analytics,claude,claude-code,decorators,fastapi,flask,growth-loop,indie-hackers,instrumentation,product-analytics,saas
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: httpx>=0.27.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# growth-loop-sdk
|
|
32
|
+
|
|
33
|
+
Python SDK for [growth-loop.dev](https://growth-loop.dev) — Claude
|
|
34
|
+
Code-native product analytics for vibe-coded SaaS. The Python module name
|
|
35
|
+
is `growth`; the PyPI distribution is `growth-loop-sdk`.
|
|
36
|
+
|
|
37
|
+
Pairs with [`@growth-loop/mcp-server`](https://www.npmjs.com/package/@growth-loop/mcp-server)
|
|
38
|
+
so Claude Code can ask your funnels, retention, and revenue questions
|
|
39
|
+
directly with citations.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pip install growth-loop-sdk
|
|
45
|
+
# or
|
|
46
|
+
uv add growth-loop-sdk
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Requires Python 3.10+.
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import os
|
|
55
|
+
from growth import Growth, GrowthOptions
|
|
56
|
+
|
|
57
|
+
growth = Growth(GrowthOptions(
|
|
58
|
+
api_key=os.environ["GROWTH_KEY"],
|
|
59
|
+
host=os.environ.get("GROWTH_HOST", "https://api.growth-loop.dev"),
|
|
60
|
+
environment=os.environ.get("APP_ENV", "production"),
|
|
61
|
+
release=os.environ.get("GIT_COMMIT_SHA"),
|
|
62
|
+
))
|
|
63
|
+
|
|
64
|
+
# Anywhere in your app:
|
|
65
|
+
growth.track("signup_completed", {"plan": "pro"})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The SDK runs a background worker thread; on `atexit` it flushes pending
|
|
69
|
+
events automatically. On serverless platforms (Lambda, Cloud Run) call
|
|
70
|
+
`growth.flush()` before the request handler returns.
|
|
71
|
+
|
|
72
|
+
## FastAPI
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from fastapi import APIRouter
|
|
76
|
+
from growth import growth_span, growth_step
|
|
77
|
+
from growth_setup import growth # the singleton from above
|
|
78
|
+
|
|
79
|
+
router = APIRouter()
|
|
80
|
+
|
|
81
|
+
@router.post("/checkout")
|
|
82
|
+
@growth_span(growth, "checkout", distinct_id=lambda body: body.user_id)
|
|
83
|
+
async def checkout(body: CheckoutBody):
|
|
84
|
+
# automatically emits checkout.started / .completed / .failed
|
|
85
|
+
# with duration_ms and error metadata
|
|
86
|
+
return await stripe.charge(body)
|
|
87
|
+
|
|
88
|
+
@router.post("/signup")
|
|
89
|
+
@growth_step(growth, "signup_completed", distinct_id=lambda body: body.user_id)
|
|
90
|
+
async def signup(body: SignupBody):
|
|
91
|
+
return await create_user(body)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Flask
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from flask import Blueprint
|
|
98
|
+
from growth import growth_span
|
|
99
|
+
from growth_setup import growth
|
|
100
|
+
|
|
101
|
+
bp = Blueprint("checkout", __name__)
|
|
102
|
+
|
|
103
|
+
@bp.route("/checkout", methods=["POST"])
|
|
104
|
+
@growth_span(growth, "checkout", distinct_id=lambda: g.user.id)
|
|
105
|
+
def checkout():
|
|
106
|
+
return stripe.charge(request.json)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Core API
|
|
110
|
+
|
|
111
|
+
### `Growth(options: GrowthOptions)`
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
@dataclass
|
|
115
|
+
class GrowthOptions:
|
|
116
|
+
api_key: str # required
|
|
117
|
+
host: str = "https://api.growth-loop.dev"
|
|
118
|
+
flush_interval: float = 5.0 # seconds
|
|
119
|
+
batch_size: int = 50
|
|
120
|
+
environment: str | None = None # "production" | "preview" | "development"
|
|
121
|
+
release: str | None = None # git SHA — used by /diagnose for commit-level attribution
|
|
122
|
+
timeout: float = 5.0 # HTTP timeout
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
| Method | What it does |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `track(name, properties=None, options=None)` | Enqueue one event. Non-blocking. |
|
|
128
|
+
| `identify(IdentifyOptions(distinct_id=..., properties=...))` | Set the current distinct ID + emit `$identify`. |
|
|
129
|
+
| `set_distinct_id(id)` | Set the ID without emitting `$identify`. |
|
|
130
|
+
| `flush()` | Force-send queued events. **Always call before request returns on serverless.** |
|
|
131
|
+
| `shutdown()` | Flush + stop the worker. Called automatically on process exit. |
|
|
132
|
+
|
|
133
|
+
### `TrackOptions`
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
@dataclass
|
|
137
|
+
class TrackOptions:
|
|
138
|
+
distinct_id: str | None = None # override the client's default
|
|
139
|
+
timestamp: datetime | None = None # override the auto-set UTC now
|
|
140
|
+
context: EventContext | None = None
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Decorators
|
|
144
|
+
|
|
145
|
+
The three decorators auto-applied by `claude /init` (the MCP slash-prompt).
|
|
146
|
+
Each preserves your function signature; types and docs flow through `@functools.wraps`.
|
|
147
|
+
|
|
148
|
+
### `@growth_span(client, name, *, properties=None, distinct_id=None)`
|
|
149
|
+
|
|
150
|
+
Emits `<name>.started`, `<name>.completed` (with `duration_ms`), or
|
|
151
|
+
`<name>.failed` (with `error_message`, `error_name`). Works on sync **and**
|
|
152
|
+
async functions automatically. Use on anything > 500ms or with non-trivial
|
|
153
|
+
failure rate.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
@growth_span(growth, "ai.generate", distinct_id=lambda req: req.user_id)
|
|
157
|
+
async def generate(req: GenerateRequest):
|
|
158
|
+
return await anthropic.messages.create(...)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`distinct_id` may be a literal string or a callable that receives the
|
|
162
|
+
wrapped function's args/kwargs and returns the ID.
|
|
163
|
+
|
|
164
|
+
### `@growth_step(client, funnel_step, *, properties=None, distinct_id=None)`
|
|
165
|
+
|
|
166
|
+
Single-event funnel marker. Drop on whatever step represents
|
|
167
|
+
*progress through your activation*.
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
@growth_step(growth, "onboarding.invited_team")
|
|
171
|
+
def invite_team(team_id: str):
|
|
172
|
+
...
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### `growth_track(client, name, properties=None)`
|
|
176
|
+
|
|
177
|
+
Imperative one-off track. Equivalent to `client.track(name, properties)`,
|
|
178
|
+
provided for symmetry with the decorator helpers.
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
growth_track(growth, "pricing_viewed", {"tier": "pro"})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Recommended event taxonomy
|
|
185
|
+
|
|
186
|
+
The MCP server's `/diagnose`, `/weekly`, and `/pmf` prompts know these names natively:
|
|
187
|
+
|
|
188
|
+
| Stage | Events |
|
|
189
|
+
|---|---|
|
|
190
|
+
| Identity | `signup_completed`, `login_completed`, `$identify` |
|
|
191
|
+
| Activation | `onboarding.started`, `onboarding.completed`, `first_<thing>_created` |
|
|
192
|
+
| Revenue | `checkout_started`, `checkout_completed`, `subscription_created`, `subscription_canceled`, `payment_failed` |
|
|
193
|
+
| Performance | `growth_span(growth, "checkout", ...)`, `growth_span(growth, "ai.generate", ...)` |
|
|
194
|
+
|
|
195
|
+
## Privacy
|
|
196
|
+
|
|
197
|
+
- **Never put PII** (email, raw IP, full address, payment details) in
|
|
198
|
+
`properties`. Use `distinct_id` for identity. Backend ClickHouse never
|
|
199
|
+
decrypts or displays `properties` as identity.
|
|
200
|
+
- ClickHouse TTL is 12 months by default — events older than that are
|
|
201
|
+
dropped automatically.
|
|
202
|
+
|
|
203
|
+
## Going deeper
|
|
204
|
+
|
|
205
|
+
The SDK is the ingest layer. The agent layer (Claude Code + MCP) is what
|
|
206
|
+
makes growth-loop different — install the MCP server too:
|
|
207
|
+
|
|
208
|
+
```sh
|
|
209
|
+
claude mcp add growth-loop -- npx -y @growth-loop/mcp-server \
|
|
210
|
+
-e GROWTH_API_KEY=pk_live_<your-key>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Then in Claude Code: `/init` (auto-instrument), `/diagnose <metric>`,
|
|
214
|
+
`/weekly`, `/pmf`, `/icp`, `/strategy`, `/pivot`.
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# growth-loop-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for [growth-loop.dev](https://growth-loop.dev) — Claude
|
|
4
|
+
Code-native product analytics for vibe-coded SaaS. The Python module name
|
|
5
|
+
is `growth`; the PyPI distribution is `growth-loop-sdk`.
|
|
6
|
+
|
|
7
|
+
Pairs with [`@growth-loop/mcp-server`](https://www.npmjs.com/package/@growth-loop/mcp-server)
|
|
8
|
+
so Claude Code can ask your funnels, retention, and revenue questions
|
|
9
|
+
directly with citations.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pip install growth-loop-sdk
|
|
15
|
+
# or
|
|
16
|
+
uv add growth-loop-sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Python 3.10+.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import os
|
|
25
|
+
from growth import Growth, GrowthOptions
|
|
26
|
+
|
|
27
|
+
growth = Growth(GrowthOptions(
|
|
28
|
+
api_key=os.environ["GROWTH_KEY"],
|
|
29
|
+
host=os.environ.get("GROWTH_HOST", "https://api.growth-loop.dev"),
|
|
30
|
+
environment=os.environ.get("APP_ENV", "production"),
|
|
31
|
+
release=os.environ.get("GIT_COMMIT_SHA"),
|
|
32
|
+
))
|
|
33
|
+
|
|
34
|
+
# Anywhere in your app:
|
|
35
|
+
growth.track("signup_completed", {"plan": "pro"})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The SDK runs a background worker thread; on `atexit` it flushes pending
|
|
39
|
+
events automatically. On serverless platforms (Lambda, Cloud Run) call
|
|
40
|
+
`growth.flush()` before the request handler returns.
|
|
41
|
+
|
|
42
|
+
## FastAPI
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from fastapi import APIRouter
|
|
46
|
+
from growth import growth_span, growth_step
|
|
47
|
+
from growth_setup import growth # the singleton from above
|
|
48
|
+
|
|
49
|
+
router = APIRouter()
|
|
50
|
+
|
|
51
|
+
@router.post("/checkout")
|
|
52
|
+
@growth_span(growth, "checkout", distinct_id=lambda body: body.user_id)
|
|
53
|
+
async def checkout(body: CheckoutBody):
|
|
54
|
+
# automatically emits checkout.started / .completed / .failed
|
|
55
|
+
# with duration_ms and error metadata
|
|
56
|
+
return await stripe.charge(body)
|
|
57
|
+
|
|
58
|
+
@router.post("/signup")
|
|
59
|
+
@growth_step(growth, "signup_completed", distinct_id=lambda body: body.user_id)
|
|
60
|
+
async def signup(body: SignupBody):
|
|
61
|
+
return await create_user(body)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Flask
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from flask import Blueprint
|
|
68
|
+
from growth import growth_span
|
|
69
|
+
from growth_setup import growth
|
|
70
|
+
|
|
71
|
+
bp = Blueprint("checkout", __name__)
|
|
72
|
+
|
|
73
|
+
@bp.route("/checkout", methods=["POST"])
|
|
74
|
+
@growth_span(growth, "checkout", distinct_id=lambda: g.user.id)
|
|
75
|
+
def checkout():
|
|
76
|
+
return stripe.charge(request.json)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Core API
|
|
80
|
+
|
|
81
|
+
### `Growth(options: GrowthOptions)`
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
@dataclass
|
|
85
|
+
class GrowthOptions:
|
|
86
|
+
api_key: str # required
|
|
87
|
+
host: str = "https://api.growth-loop.dev"
|
|
88
|
+
flush_interval: float = 5.0 # seconds
|
|
89
|
+
batch_size: int = 50
|
|
90
|
+
environment: str | None = None # "production" | "preview" | "development"
|
|
91
|
+
release: str | None = None # git SHA — used by /diagnose for commit-level attribution
|
|
92
|
+
timeout: float = 5.0 # HTTP timeout
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
| Method | What it does |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `track(name, properties=None, options=None)` | Enqueue one event. Non-blocking. |
|
|
98
|
+
| `identify(IdentifyOptions(distinct_id=..., properties=...))` | Set the current distinct ID + emit `$identify`. |
|
|
99
|
+
| `set_distinct_id(id)` | Set the ID without emitting `$identify`. |
|
|
100
|
+
| `flush()` | Force-send queued events. **Always call before request returns on serverless.** |
|
|
101
|
+
| `shutdown()` | Flush + stop the worker. Called automatically on process exit. |
|
|
102
|
+
|
|
103
|
+
### `TrackOptions`
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
@dataclass
|
|
107
|
+
class TrackOptions:
|
|
108
|
+
distinct_id: str | None = None # override the client's default
|
|
109
|
+
timestamp: datetime | None = None # override the auto-set UTC now
|
|
110
|
+
context: EventContext | None = None
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Decorators
|
|
114
|
+
|
|
115
|
+
The three decorators auto-applied by `claude /init` (the MCP slash-prompt).
|
|
116
|
+
Each preserves your function signature; types and docs flow through `@functools.wraps`.
|
|
117
|
+
|
|
118
|
+
### `@growth_span(client, name, *, properties=None, distinct_id=None)`
|
|
119
|
+
|
|
120
|
+
Emits `<name>.started`, `<name>.completed` (with `duration_ms`), or
|
|
121
|
+
`<name>.failed` (with `error_message`, `error_name`). Works on sync **and**
|
|
122
|
+
async functions automatically. Use on anything > 500ms or with non-trivial
|
|
123
|
+
failure rate.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
@growth_span(growth, "ai.generate", distinct_id=lambda req: req.user_id)
|
|
127
|
+
async def generate(req: GenerateRequest):
|
|
128
|
+
return await anthropic.messages.create(...)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`distinct_id` may be a literal string or a callable that receives the
|
|
132
|
+
wrapped function's args/kwargs and returns the ID.
|
|
133
|
+
|
|
134
|
+
### `@growth_step(client, funnel_step, *, properties=None, distinct_id=None)`
|
|
135
|
+
|
|
136
|
+
Single-event funnel marker. Drop on whatever step represents
|
|
137
|
+
*progress through your activation*.
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
@growth_step(growth, "onboarding.invited_team")
|
|
141
|
+
def invite_team(team_id: str):
|
|
142
|
+
...
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### `growth_track(client, name, properties=None)`
|
|
146
|
+
|
|
147
|
+
Imperative one-off track. Equivalent to `client.track(name, properties)`,
|
|
148
|
+
provided for symmetry with the decorator helpers.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
growth_track(growth, "pricing_viewed", {"tier": "pro"})
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Recommended event taxonomy
|
|
155
|
+
|
|
156
|
+
The MCP server's `/diagnose`, `/weekly`, and `/pmf` prompts know these names natively:
|
|
157
|
+
|
|
158
|
+
| Stage | Events |
|
|
159
|
+
|---|---|
|
|
160
|
+
| Identity | `signup_completed`, `login_completed`, `$identify` |
|
|
161
|
+
| Activation | `onboarding.started`, `onboarding.completed`, `first_<thing>_created` |
|
|
162
|
+
| Revenue | `checkout_started`, `checkout_completed`, `subscription_created`, `subscription_canceled`, `payment_failed` |
|
|
163
|
+
| Performance | `growth_span(growth, "checkout", ...)`, `growth_span(growth, "ai.generate", ...)` |
|
|
164
|
+
|
|
165
|
+
## Privacy
|
|
166
|
+
|
|
167
|
+
- **Never put PII** (email, raw IP, full address, payment details) in
|
|
168
|
+
`properties`. Use `distinct_id` for identity. Backend ClickHouse never
|
|
169
|
+
decrypts or displays `properties` as identity.
|
|
170
|
+
- ClickHouse TTL is 12 months by default — events older than that are
|
|
171
|
+
dropped automatically.
|
|
172
|
+
|
|
173
|
+
## Going deeper
|
|
174
|
+
|
|
175
|
+
The SDK is the ingest layer. The agent layer (Claude Code + MCP) is what
|
|
176
|
+
makes growth-loop different — install the MCP server too:
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
claude mcp add growth-loop -- npx -y @growth-loop/mcp-server \
|
|
180
|
+
-e GROWTH_API_KEY=pk_live_<your-key>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Then in Claude Code: `/init` (auto-instrument), `/diagnose <metric>`,
|
|
184
|
+
`/weekly`, `/pmf`, `/icp`, `/strategy`, `/pivot`.
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "growth-loop-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Growth Python SDK — drop-in product analytics for vibe-coded SaaS. Pairs with the Growth MCP server to give Claude Code an AI growth engineer for your Python app."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "growth-loop.dev" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"analytics",
|
|
15
|
+
"product-analytics",
|
|
16
|
+
"saas",
|
|
17
|
+
"indie-hackers",
|
|
18
|
+
"claude",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"growth-loop",
|
|
21
|
+
"decorators",
|
|
22
|
+
"instrumentation",
|
|
23
|
+
"fastapi",
|
|
24
|
+
"flask",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 4 - Beta",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"License :: OSI Approved :: MIT License",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.10",
|
|
33
|
+
"Programming Language :: Python :: 3.11",
|
|
34
|
+
"Programming Language :: Python :: 3.12",
|
|
35
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
36
|
+
"Topic :: System :: Monitoring",
|
|
37
|
+
]
|
|
38
|
+
dependencies = [
|
|
39
|
+
"httpx>=0.27.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=8.0",
|
|
45
|
+
"pytest-asyncio>=0.23",
|
|
46
|
+
"ruff>=0.6",
|
|
47
|
+
"mypy>=1.10",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[project.urls]
|
|
51
|
+
Homepage = "https://growth-loop.dev"
|
|
52
|
+
Repository = "https://gitlab.com/AKnyaZP/metrics"
|
|
53
|
+
Issues = "https://gitlab.com/AKnyaZP/metrics/-/issues"
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel]
|
|
56
|
+
packages = ["src/growth"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 100
|
|
60
|
+
target-version = "py310"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "W", "I", "B", "UP", "RUF"]
|
|
64
|
+
|
|
65
|
+
[tool.mypy]
|
|
66
|
+
strict = true
|
|
67
|
+
python_version = "3.10"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from growth.client import Growth, GrowthOptions, IdentifyOptions, TrackOptions
|
|
2
|
+
from growth.decorators import growth_span, growth_step, growth_track
|
|
3
|
+
from growth.types import EventContext, Properties, TrackEvent
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Growth",
|
|
7
|
+
"GrowthOptions",
|
|
8
|
+
"IdentifyOptions",
|
|
9
|
+
"TrackOptions",
|
|
10
|
+
"EventContext",
|
|
11
|
+
"Properties",
|
|
12
|
+
"TrackEvent",
|
|
13
|
+
"growth_span",
|
|
14
|
+
"growth_step",
|
|
15
|
+
"growth_track",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from queue import Empty, Queue
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from growth.types import EventContext, Properties, TrackEvent
|
|
15
|
+
|
|
16
|
+
_DEFAULT_HOST = "https://api.growth-loop.dev"
|
|
17
|
+
_INGEST_PATH = "/v1/ingest"
|
|
18
|
+
_DEFAULT_FLUSH_INTERVAL = 5.0
|
|
19
|
+
_DEFAULT_BATCH_SIZE = 50
|
|
20
|
+
_SDK_NAME = "growth-py"
|
|
21
|
+
_SDK_VERSION = "0.0.1"
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("growth")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class GrowthOptions:
|
|
28
|
+
api_key: str
|
|
29
|
+
host: str = _DEFAULT_HOST
|
|
30
|
+
flush_interval: float = _DEFAULT_FLUSH_INTERVAL
|
|
31
|
+
batch_size: int = _DEFAULT_BATCH_SIZE
|
|
32
|
+
environment: str | None = None
|
|
33
|
+
release: str | None = None
|
|
34
|
+
timeout: float = 5.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class TrackOptions:
|
|
39
|
+
distinct_id: str | None = None
|
|
40
|
+
timestamp: datetime | None = None
|
|
41
|
+
context: EventContext | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class IdentifyOptions:
|
|
46
|
+
distinct_id: str
|
|
47
|
+
properties: Properties | None = None
|
|
48
|
+
context: EventContext | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Growth:
|
|
52
|
+
def __init__(self, options: GrowthOptions) -> None:
|
|
53
|
+
if not options.api_key:
|
|
54
|
+
raise ValueError("api_key is required")
|
|
55
|
+
self._options = options
|
|
56
|
+
self._queue: Queue[TrackEvent] = Queue()
|
|
57
|
+
self._stopping = threading.Event()
|
|
58
|
+
self._distinct_id = str(uuid.uuid4())
|
|
59
|
+
self._client = httpx.Client(timeout=options.timeout)
|
|
60
|
+
self._worker = threading.Thread(target=self._run, name="growth-flush", daemon=True)
|
|
61
|
+
self._worker.start()
|
|
62
|
+
atexit.register(self.shutdown)
|
|
63
|
+
|
|
64
|
+
def set_distinct_id(self, distinct_id: str) -> None:
|
|
65
|
+
self._distinct_id = distinct_id
|
|
66
|
+
|
|
67
|
+
def track(
|
|
68
|
+
self,
|
|
69
|
+
name: str,
|
|
70
|
+
properties: Properties | None = None,
|
|
71
|
+
options: TrackOptions | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
if self._stopping.is_set():
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
ts = (options.timestamp if options and options.timestamp else datetime.now(timezone.utc))
|
|
77
|
+
event = TrackEvent(
|
|
78
|
+
name=name,
|
|
79
|
+
distinct_id=(options.distinct_id if options and options.distinct_id else self._distinct_id),
|
|
80
|
+
timestamp=ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
81
|
+
properties=properties,
|
|
82
|
+
context=self._build_context(options.context if options else None),
|
|
83
|
+
)
|
|
84
|
+
self._queue.put_nowait(event)
|
|
85
|
+
|
|
86
|
+
def identify(self, options: IdentifyOptions) -> None:
|
|
87
|
+
self._distinct_id = options.distinct_id
|
|
88
|
+
self.track(
|
|
89
|
+
"$identify",
|
|
90
|
+
options.properties,
|
|
91
|
+
TrackOptions(distinct_id=options.distinct_id, context=options.context),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def alias(self, distinct_id: str, previous_id: str) -> None:
|
|
95
|
+
self.track(
|
|
96
|
+
"$alias",
|
|
97
|
+
{"previous_id": previous_id},
|
|
98
|
+
TrackOptions(distinct_id=distinct_id),
|
|
99
|
+
)
|
|
100
|
+
self._distinct_id = distinct_id
|
|
101
|
+
|
|
102
|
+
def flush(self) -> None:
|
|
103
|
+
events = self._drain()
|
|
104
|
+
if events:
|
|
105
|
+
self._send(events)
|
|
106
|
+
|
|
107
|
+
def shutdown(self) -> None:
|
|
108
|
+
if self._stopping.is_set():
|
|
109
|
+
return
|
|
110
|
+
self._stopping.set()
|
|
111
|
+
self.flush()
|
|
112
|
+
self._client.close()
|
|
113
|
+
|
|
114
|
+
def _run(self) -> None:
|
|
115
|
+
while not self._stopping.is_set():
|
|
116
|
+
try:
|
|
117
|
+
event = self._queue.get(timeout=self._options.flush_interval)
|
|
118
|
+
except Empty:
|
|
119
|
+
self.flush()
|
|
120
|
+
continue
|
|
121
|
+
batch: list[TrackEvent] = [event]
|
|
122
|
+
while len(batch) < self._options.batch_size:
|
|
123
|
+
try:
|
|
124
|
+
batch.append(self._queue.get_nowait())
|
|
125
|
+
except Empty:
|
|
126
|
+
break
|
|
127
|
+
self._send(batch)
|
|
128
|
+
|
|
129
|
+
def _drain(self) -> list[TrackEvent]:
|
|
130
|
+
out: list[TrackEvent] = []
|
|
131
|
+
while True:
|
|
132
|
+
try:
|
|
133
|
+
out.append(self._queue.get_nowait())
|
|
134
|
+
except Empty:
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
def _send(self, events: list[TrackEvent]) -> None:
|
|
138
|
+
if not events:
|
|
139
|
+
return
|
|
140
|
+
payload: dict[str, Any] = {
|
|
141
|
+
"api_key": self._options.api_key,
|
|
142
|
+
"sdk": {"name": _SDK_NAME, "version": _SDK_VERSION},
|
|
143
|
+
"sent_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
144
|
+
"events": [e.to_dict() for e in events],
|
|
145
|
+
}
|
|
146
|
+
url = self._options.host.rstrip("/") + _INGEST_PATH
|
|
147
|
+
try:
|
|
148
|
+
response = self._client.post(
|
|
149
|
+
url,
|
|
150
|
+
json=payload,
|
|
151
|
+
headers={"x-growth-sdk": f"{_SDK_NAME}/{_SDK_VERSION}"},
|
|
152
|
+
)
|
|
153
|
+
response.raise_for_status()
|
|
154
|
+
except httpx.HTTPError as exc:
|
|
155
|
+
logger.warning("growth ingest failed: %s", exc)
|
|
156
|
+
|
|
157
|
+
def _build_context(self, context: EventContext | None) -> dict[str, Any]:
|
|
158
|
+
merged: dict[str, Any] = {}
|
|
159
|
+
if self._options.release:
|
|
160
|
+
merged["release"] = self._options.release
|
|
161
|
+
if self._options.environment:
|
|
162
|
+
merged["environment"] = self._options.environment
|
|
163
|
+
if context:
|
|
164
|
+
merged.update(context.to_dict())
|
|
165
|
+
return merged
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Higher-level instrumentation: spans, steps, tracked events.
|
|
3
|
+
|
|
4
|
+
These wrap the low-level `track()` API in patterns the agent (and a human)
|
|
5
|
+
can drop into existing code with minimal change. Designed to be applied
|
|
6
|
+
automatically by `claude /growth init`.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
|
|
10
|
+
from growth import Growth, GrowthOptions
|
|
11
|
+
|
|
12
|
+
growth = Growth(GrowthOptions(api_key="pk_live_…", host="https://api.growth-loop.dev"))
|
|
13
|
+
|
|
14
|
+
@growth_span(growth, "checkout")
|
|
15
|
+
def run_checkout(user_id: str): ...
|
|
16
|
+
|
|
17
|
+
@growth_step(growth, "activation.invited_team")
|
|
18
|
+
def invite_team(team_id: str): ...
|
|
19
|
+
|
|
20
|
+
growth_track(growth, "login_clicked")
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import functools
|
|
26
|
+
import inspect
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Awaitable, Callable
|
|
29
|
+
from typing import Any, ParamSpec, TypeVar, cast, overload
|
|
30
|
+
|
|
31
|
+
from growth.client import Growth, TrackOptions
|
|
32
|
+
from growth.types import Properties
|
|
33
|
+
|
|
34
|
+
P = ParamSpec("P")
|
|
35
|
+
T = TypeVar("T")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def growth_span(
|
|
39
|
+
client: Growth,
|
|
40
|
+
name: str,
|
|
41
|
+
*,
|
|
42
|
+
properties: Properties | None = None,
|
|
43
|
+
distinct_id: Callable[..., str | None] | str | None = None,
|
|
44
|
+
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
|
45
|
+
"""Decorator that emits `<name>.started`, `<name>.completed`, `<name>.failed`
|
|
46
|
+
events with duration_ms. Works on sync and async functions.
|
|
47
|
+
|
|
48
|
+
`distinct_id` may be a string (literal user id) or a callable that receives
|
|
49
|
+
the wrapped function's args/kwargs and returns the id.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def _resolve_id(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str | None:
|
|
53
|
+
if callable(distinct_id):
|
|
54
|
+
try:
|
|
55
|
+
return distinct_id(*args, **kwargs)
|
|
56
|
+
except Exception:
|
|
57
|
+
return None
|
|
58
|
+
return distinct_id
|
|
59
|
+
|
|
60
|
+
def decorator(fn: Callable[P, T]) -> Callable[P, T]:
|
|
61
|
+
if inspect.iscoroutinefunction(fn):
|
|
62
|
+
@functools.wraps(fn)
|
|
63
|
+
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
64
|
+
started_at = time.monotonic()
|
|
65
|
+
base = dict(properties or {})
|
|
66
|
+
opts = TrackOptions(distinct_id=_resolve_id(args, kwargs))
|
|
67
|
+
client.track(f"{name}.started", base, opts)
|
|
68
|
+
try:
|
|
69
|
+
result = await cast(Awaitable[T], fn(*args, **kwargs))
|
|
70
|
+
client.track(
|
|
71
|
+
f"{name}.completed",
|
|
72
|
+
{**base, "duration_ms": int((time.monotonic() - started_at) * 1000)},
|
|
73
|
+
opts,
|
|
74
|
+
)
|
|
75
|
+
return result
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
client.track(
|
|
78
|
+
f"{name}.failed",
|
|
79
|
+
{
|
|
80
|
+
**base,
|
|
81
|
+
"duration_ms": int((time.monotonic() - started_at) * 1000),
|
|
82
|
+
"error_message": str(exc)[:500],
|
|
83
|
+
"error_name": type(exc).__name__,
|
|
84
|
+
},
|
|
85
|
+
opts,
|
|
86
|
+
)
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
return cast(Callable[P, T], async_wrapper)
|
|
90
|
+
|
|
91
|
+
@functools.wraps(fn)
|
|
92
|
+
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
93
|
+
started_at = time.monotonic()
|
|
94
|
+
base = dict(properties or {})
|
|
95
|
+
opts = TrackOptions(distinct_id=_resolve_id(args, kwargs))
|
|
96
|
+
client.track(f"{name}.started", base, opts)
|
|
97
|
+
try:
|
|
98
|
+
result = fn(*args, **kwargs)
|
|
99
|
+
client.track(
|
|
100
|
+
f"{name}.completed",
|
|
101
|
+
{**base, "duration_ms": int((time.monotonic() - started_at) * 1000)},
|
|
102
|
+
opts,
|
|
103
|
+
)
|
|
104
|
+
return result
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
client.track(
|
|
107
|
+
f"{name}.failed",
|
|
108
|
+
{
|
|
109
|
+
**base,
|
|
110
|
+
"duration_ms": int((time.monotonic() - started_at) * 1000),
|
|
111
|
+
"error_message": str(exc)[:500],
|
|
112
|
+
"error_name": type(exc).__name__,
|
|
113
|
+
},
|
|
114
|
+
opts,
|
|
115
|
+
)
|
|
116
|
+
raise
|
|
117
|
+
|
|
118
|
+
return sync_wrapper
|
|
119
|
+
|
|
120
|
+
return decorator
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def growth_step(
|
|
124
|
+
client: Growth,
|
|
125
|
+
funnel_step: str,
|
|
126
|
+
*,
|
|
127
|
+
properties: Properties | None = None,
|
|
128
|
+
distinct_id: Callable[..., str | None] | str | None = None,
|
|
129
|
+
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
|
130
|
+
"""Decorator that emits a single funnel-step event when the wrapped function runs."""
|
|
131
|
+
|
|
132
|
+
def decorator(fn: Callable[P, T]) -> Callable[P, T]:
|
|
133
|
+
@functools.wraps(fn)
|
|
134
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
135
|
+
opts: TrackOptions | None = None
|
|
136
|
+
if callable(distinct_id):
|
|
137
|
+
try:
|
|
138
|
+
opts = TrackOptions(distinct_id=distinct_id(*args, **kwargs))
|
|
139
|
+
except Exception:
|
|
140
|
+
opts = None
|
|
141
|
+
elif distinct_id is not None:
|
|
142
|
+
opts = TrackOptions(distinct_id=distinct_id)
|
|
143
|
+
client.track(funnel_step, dict(properties or {}), opts)
|
|
144
|
+
return fn(*args, **kwargs)
|
|
145
|
+
|
|
146
|
+
return wrapper
|
|
147
|
+
|
|
148
|
+
return decorator
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@overload
|
|
152
|
+
def growth_track(client: Growth, name: str) -> None: ...
|
|
153
|
+
@overload
|
|
154
|
+
def growth_track(
|
|
155
|
+
client: Growth, name: str, properties: Properties | None
|
|
156
|
+
) -> None: ...
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def growth_track(
|
|
160
|
+
client: Growth, name: str, properties: Properties | None = None
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Synchronous one-off track. Equivalent to `client.track(name, properties)`."""
|
|
163
|
+
client.track(name, properties)
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
Properties = dict[str, Any]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class EventContext:
|
|
11
|
+
page_url: str | None = None
|
|
12
|
+
page_path: str | None = None
|
|
13
|
+
page_title: str | None = None
|
|
14
|
+
referrer: str | None = None
|
|
15
|
+
user_agent: str | None = None
|
|
16
|
+
ip: str | None = None
|
|
17
|
+
locale: str | None = None
|
|
18
|
+
timezone: str | None = None
|
|
19
|
+
utm_source: str | None = None
|
|
20
|
+
utm_medium: str | None = None
|
|
21
|
+
utm_campaign: str | None = None
|
|
22
|
+
release: str | None = None
|
|
23
|
+
commit_sha: str | None = None
|
|
24
|
+
deploy_id: str | None = None
|
|
25
|
+
environment: str | None = None
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
return {k: v for k, v in self.__dict__.items() if v is not None}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class TrackEvent:
|
|
33
|
+
name: str
|
|
34
|
+
distinct_id: str
|
|
35
|
+
timestamp: str
|
|
36
|
+
source: str = "server"
|
|
37
|
+
properties: Properties | None = None
|
|
38
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
session_id: str | None = None
|
|
40
|
+
anonymous_id: str | None = None
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]:
|
|
43
|
+
out: dict[str, Any] = {
|
|
44
|
+
"name": self.name,
|
|
45
|
+
"distinct_id": self.distinct_id,
|
|
46
|
+
"timestamp": self.timestamp,
|
|
47
|
+
"source": self.source,
|
|
48
|
+
}
|
|
49
|
+
if self.properties:
|
|
50
|
+
out["properties"] = self.properties
|
|
51
|
+
if self.context:
|
|
52
|
+
out["context"] = self.context
|
|
53
|
+
if self.session_id:
|
|
54
|
+
out["session_id"] = self.session_id
|
|
55
|
+
if self.anonymous_id:
|
|
56
|
+
out["anonymous_id"] = self.anonymous_id
|
|
57
|
+
return out
|