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.
@@ -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