apphelm 0.5.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,5 @@
1
+ # Build outputs of `python -m build` and test runs.
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
apphelm-0.5.0/LICENSE ADDED
@@ -0,0 +1,3 @@
1
+ Copyright 2026 SansaVision. All rights reserved.
2
+
3
+ This package may be used only under an active Apphelm service agreement. Redistribution, sublicensing, and reverse engineering are prohibited except where applicable law does not permit those restrictions.
apphelm-0.5.0/PKG-INFO ADDED
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.5
2
+ Name: apphelm
3
+ Version: 0.5.0
4
+ Summary: Official Python server SDK for Apphelm: events, identify, fail-closed feature flags, errors and tracing.
5
+ Project-URL: Homepage, https://apphelm.dev
6
+ Project-URL: Documentation, https://apphelm.dev/docs
7
+ Author-email: SansaVision <support@sansavision.com>
8
+ License-Expression: LicenseRef-Proprietary
9
+ License-File: LICENSE
10
+ Keywords: analytics,apphelm,error-monitoring,feature-flags,tracing
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Topic :: System :: Monitoring
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Apphelm Python SDK
22
+
23
+ The official Python client for [Apphelm](https://apphelm.dev), for server-side code: product
24
+ events, identify, fail-closed feature flags, error and message reporting, cron check-ins and
25
+ tracing. One module, standard library only, Python 3.10 or later.
26
+
27
+ ```bash
28
+ pip install apphelm
29
+ ```
30
+
31
+ Use a server API key (`ahl_…`) from your Apphelm workspace and keep it secret. Never ship it in
32
+ a browser or mobile app.
33
+
34
+ ```py
35
+ import os
36
+ from apphelm import Apphelm
37
+
38
+ apphelm = Apphelm(api_key=os.environ["APPHELM_API_KEY"])
39
+
40
+ # A product event for one person.
41
+ apphelm.capture("org_…", "prd_…", distinct_id=customer.id, event_name="invoice_sent")
42
+
43
+ # Feature flags fail closed: any network, timeout or API error evaluates to False.
44
+ if apphelm.is_enabled("new-checkout", customer.id, product_id="prd_…"):
45
+ render_new_checkout()
46
+ variant = apphelm.get_feature_flag("org_…", "prd_…", "pricing-test", customer.id) # variant, True or False
47
+ ```
48
+
49
+ ## Identify
50
+
51
+ Every trait needs provenance. A trait without a matching `trait_sources` entry raises
52
+ `ValueError` before anything is sent, because the API would reject it.
53
+
54
+ ```py
55
+ apphelm.identify("org_…", "prd_…", external_user_id=account.id,
56
+ traits={"plan": "builder"},
57
+ trait_sources={"plan": {"sourceType": "auth", "sourceId": "session", "verification": "integration_verified"}})
58
+ ```
59
+
60
+ ## Errors and messages
61
+
62
+ ```py
63
+ try:
64
+ charge(order)
65
+ except Exception as error:
66
+ apphelm.capture_exception("org_…", "prd_…", error, release=os.environ.get("RELEASE"),
67
+ environment="production", tags={"route": "/charges"}, user_id=account.id)
68
+
69
+ # Report uncaught exceptions (main thread and other threads) as unhandled issues.
70
+ # The previous hooks still run, so the traceback prints and the process exits as before.
71
+ restore = apphelm.capture_unhandled("org_…", "prd_…", release=os.environ.get("RELEASE"), environment="production")
72
+
73
+ apphelm.capture_message("org_…", "prd_…", "Payment provider fell back to retries", level="warning")
74
+ ```
75
+
76
+ Tracebacks are scrubbed of email addresses, keys and query strings before they are sent, and
77
+ `user_id` is sent only as a hash. Pass `before_send=` to the constructor to change or drop
78
+ exceptions and messages: return the properties (changed or not), or `None` to drop the event. A
79
+ hook that raises drops the event.
80
+
81
+ ## Tracing
82
+
83
+ ```py
84
+ # Continue the caller's trace (from the browser or another service), or start a new one.
85
+ with apphelm.start_transaction("org_…", "prd_…", "POST /charges", traceparent=request.headers.get("traceparent")) as tx:
86
+ with tx.start_span("db.query", "select order"):
87
+ ...
88
+ ```
89
+
90
+ `Apphelm(api_key, traces_sample_rate=0.2)` keeps a share of new transactions; a transaction that
91
+ continues an incoming `traceparent` keeps the caller's sampling decision.
92
+
93
+ ## Cron check-ins
94
+
95
+ ```py
96
+ apphelm.check_in("prd_…", monitor="nightly-export", status="ok", duration_ms=5400)
97
+ ```
98
+
99
+ ## Errors from the client
100
+
101
+ Failed requests raise `ApphelmError` with `status` and `code`. Flag checks (`is_enabled`,
102
+ `get_feature_flag`) never raise for a failed request; they return `False`.
103
+
104
+ ## Licence
105
+
106
+ Proprietary. See `LICENSE`: use requires an active Apphelm service agreement.
107
+
108
+ Releasing this package: `docs/SDK-RELEASING.md` in the Apphelm repository.
@@ -0,0 +1,88 @@
1
+ # Apphelm Python SDK
2
+
3
+ The official Python client for [Apphelm](https://apphelm.dev), for server-side code: product
4
+ events, identify, fail-closed feature flags, error and message reporting, cron check-ins and
5
+ tracing. One module, standard library only, Python 3.10 or later.
6
+
7
+ ```bash
8
+ pip install apphelm
9
+ ```
10
+
11
+ Use a server API key (`ahl_…`) from your Apphelm workspace and keep it secret. Never ship it in
12
+ a browser or mobile app.
13
+
14
+ ```py
15
+ import os
16
+ from apphelm import Apphelm
17
+
18
+ apphelm = Apphelm(api_key=os.environ["APPHELM_API_KEY"])
19
+
20
+ # A product event for one person.
21
+ apphelm.capture("org_…", "prd_…", distinct_id=customer.id, event_name="invoice_sent")
22
+
23
+ # Feature flags fail closed: any network, timeout or API error evaluates to False.
24
+ if apphelm.is_enabled("new-checkout", customer.id, product_id="prd_…"):
25
+ render_new_checkout()
26
+ variant = apphelm.get_feature_flag("org_…", "prd_…", "pricing-test", customer.id) # variant, True or False
27
+ ```
28
+
29
+ ## Identify
30
+
31
+ Every trait needs provenance. A trait without a matching `trait_sources` entry raises
32
+ `ValueError` before anything is sent, because the API would reject it.
33
+
34
+ ```py
35
+ apphelm.identify("org_…", "prd_…", external_user_id=account.id,
36
+ traits={"plan": "builder"},
37
+ trait_sources={"plan": {"sourceType": "auth", "sourceId": "session", "verification": "integration_verified"}})
38
+ ```
39
+
40
+ ## Errors and messages
41
+
42
+ ```py
43
+ try:
44
+ charge(order)
45
+ except Exception as error:
46
+ apphelm.capture_exception("org_…", "prd_…", error, release=os.environ.get("RELEASE"),
47
+ environment="production", tags={"route": "/charges"}, user_id=account.id)
48
+
49
+ # Report uncaught exceptions (main thread and other threads) as unhandled issues.
50
+ # The previous hooks still run, so the traceback prints and the process exits as before.
51
+ restore = apphelm.capture_unhandled("org_…", "prd_…", release=os.environ.get("RELEASE"), environment="production")
52
+
53
+ apphelm.capture_message("org_…", "prd_…", "Payment provider fell back to retries", level="warning")
54
+ ```
55
+
56
+ Tracebacks are scrubbed of email addresses, keys and query strings before they are sent, and
57
+ `user_id` is sent only as a hash. Pass `before_send=` to the constructor to change or drop
58
+ exceptions and messages: return the properties (changed or not), or `None` to drop the event. A
59
+ hook that raises drops the event.
60
+
61
+ ## Tracing
62
+
63
+ ```py
64
+ # Continue the caller's trace (from the browser or another service), or start a new one.
65
+ with apphelm.start_transaction("org_…", "prd_…", "POST /charges", traceparent=request.headers.get("traceparent")) as tx:
66
+ with tx.start_span("db.query", "select order"):
67
+ ...
68
+ ```
69
+
70
+ `Apphelm(api_key, traces_sample_rate=0.2)` keeps a share of new transactions; a transaction that
71
+ continues an incoming `traceparent` keeps the caller's sampling decision.
72
+
73
+ ## Cron check-ins
74
+
75
+ ```py
76
+ apphelm.check_in("prd_…", monitor="nightly-export", status="ok", duration_ms=5400)
77
+ ```
78
+
79
+ ## Errors from the client
80
+
81
+ Failed requests raise `ApphelmError` with `status` and `code`. Flag checks (`is_enabled`,
82
+ `get_feature_flag`) never raise for a failed request; they return `False`.
83
+
84
+ ## Licence
85
+
86
+ Proprietary. See `LICENSE`: use requires an active Apphelm service agreement.
87
+
88
+ Releasing this package: `docs/SDK-RELEASING.md` in the Apphelm repository.
@@ -0,0 +1,467 @@
1
+ """Apphelm Python client. Server-side capture, identify, and fail-closed flag evaluation.
2
+
3
+ Requires Python 3.10+. No third-party dependencies (stdlib only).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import random
12
+ import re
13
+ import sys
14
+ import time
15
+ import threading
16
+ import traceback
17
+ import uuid
18
+ import urllib.error
19
+ import urllib.request
20
+ from dataclasses import dataclass
21
+ from datetime import datetime, timezone
22
+ from typing import Any, Callable, Optional, Sequence
23
+
24
+ __version__ = "0.5.0"
25
+
26
+
27
+ def _scrub_stack(stack: str) -> str:
28
+ """Keep frames useful for grouping without sending emails, keys or query strings."""
29
+ lines = []
30
+ for line in stack.splitlines()[-20:]:
31
+ line = re.sub(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "[email]", line)
32
+ line = re.sub(r"ahl_[A-Za-z0-9._:-]{8,160}", "[key]", line)
33
+ line = re.sub(r"\?[^\s)\"]*", "", line)
34
+ lines.append(line.rstrip()[:200])
35
+ return "\n".join(lines)[:2000]
36
+
37
+
38
+ class ApphelmError(Exception):
39
+ def __init__(self, message: str, status: int = 0, code: str = "request_failed"):
40
+ super().__init__(message)
41
+ self.status = status
42
+ self.code = code
43
+
44
+
45
+ @dataclass
46
+ class FlagEvaluation:
47
+ key: str
48
+ enabled: bool
49
+ variant: Optional[str]
50
+ payload: Any
51
+ reason: str
52
+
53
+
54
+ _TRACEPARENT = re.compile(r"^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$")
55
+ _MAX_SPANS = 50
56
+
57
+
58
+ class Span:
59
+ """A timed piece of work inside a transaction (a query, an outgoing call). Use it as a
60
+ context manager, or call ``finish``; an exception inside the ``with`` marks it an error."""
61
+
62
+ def __init__(self, transaction: "Transaction", op: str, description: Optional[str]):
63
+ self.span_id = os.urandom(8).hex()
64
+ self._transaction = transaction
65
+ self._op = op
66
+ self._description = description[:200] if description else None
67
+ self._start = time.monotonic()
68
+ self._finished = False
69
+
70
+ def finish(self, status: str = "ok") -> None:
71
+ if self._finished:
72
+ return
73
+ self._finished = True
74
+ self._transaction._add_span({
75
+ "span_id": self.span_id, "parent_span_id": self._transaction.span_id, "op": self._op,
76
+ **({"description": self._description} if self._description else {}),
77
+ "start_offset_ms": round((self._start - self._transaction._start) * 1000),
78
+ "duration_ms": round((time.monotonic() - self._start) * 1000),
79
+ "status": "error" if status == "error" else "ok",
80
+ })
81
+
82
+ def __enter__(self) -> "Span":
83
+ return self
84
+
85
+ def __exit__(self, exc_type, exc, tb) -> bool:
86
+ self.finish("error" if exc_type is not None else "ok")
87
+ return False
88
+
89
+
90
+ class Transaction:
91
+ """A sampled unit of work (a request, a job) with up to 50 child spans, sent as one
92
+ ``$transaction`` event when it finishes. Unsampled transactions send nothing. Pass
93
+ ``traceparent()`` to services you call so their work joins the same trace, and link
94
+ errors with ``capture_exception(..., trace_id=transaction.trace_id)``."""
95
+
96
+ def __init__(self, client: "Apphelm", workspace_id: str, product_id: str, name: str, op: str,
97
+ trace_id: str, parent_span_id: Optional[str], sampled: bool,
98
+ release: Optional[str], environment: Optional[str]):
99
+ self.trace_id = trace_id
100
+ self.span_id = os.urandom(8).hex()
101
+ self.sampled = sampled
102
+ self._client = client
103
+ self._scope = (workspace_id, product_id)
104
+ self._name = name[:200]
105
+ self._op = op
106
+ self._parent = parent_span_id
107
+ self._release = release
108
+ self._environment = environment
109
+ self._started_at = datetime.now(timezone.utc)
110
+ self._start = time.monotonic()
111
+ self._spans: list[dict] = []
112
+ self._lock = threading.Lock()
113
+ self._finished = False
114
+
115
+ def _add_span(self, span: dict) -> None:
116
+ with self._lock:
117
+ if len(self._spans) < _MAX_SPANS:
118
+ self._spans.append(span)
119
+
120
+ def start_span(self, op: str, description: Optional[str] = None) -> Span:
121
+ return Span(self, op, description)
122
+
123
+ def traceparent(self) -> str:
124
+ """A W3C ``traceparent`` header value that continues this trace in another service."""
125
+ return f"00-{self.trace_id}-{self.span_id}-{'01' if self.sampled else '00'}"
126
+
127
+ def finish(self, status: str = "ok") -> Optional[dict]:
128
+ """Send the transaction once. A failed send never raises: telemetry must not break the
129
+ work it measures."""
130
+ if self._finished or not self.sampled:
131
+ self._finished = True
132
+ return None
133
+ self._finished = True
134
+ properties: dict[str, Any] = {
135
+ "trace_id": self.trace_id, "span_id": self.span_id, "name": self._name, "op": self._op,
136
+ "status": "error" if status == "error" else "ok",
137
+ "started_at": self._started_at.isoformat().replace("+00:00", "Z"),
138
+ "duration_ms": round((time.monotonic() - self._start) * 1000),
139
+ "spans": json.dumps(self._spans),
140
+ }
141
+ if self._parent:
142
+ properties["parent_span_id"] = self._parent
143
+ if self._release:
144
+ properties["release"] = str(self._release)[:120]
145
+ if self._environment:
146
+ properties["environment"] = str(self._environment)[:40]
147
+ try:
148
+ return self._client.ingest(self._scope[0], self._scope[1], "$transaction", properties)
149
+ except ApphelmError:
150
+ return None
151
+
152
+ def __enter__(self) -> "Transaction":
153
+ return self
154
+
155
+ def __exit__(self, exc_type, exc, tb) -> bool:
156
+ self.finish("error" if exc_type is not None else "ok")
157
+ return False
158
+
159
+
160
+ class Apphelm:
161
+ def __init__(self, api_key: str, base_url: str = "https://apphelm.dev", timeout_seconds: float = 10.0,
162
+ traces_sample_rate: float = 1.0,
163
+ before_send: Optional[Callable[[dict], Optional[dict]]] = None):
164
+ """``traces_sample_rate`` (0-1, default 1) is the share of transactions started here
165
+ that are kept; a transaction continuing an incoming ``traceparent`` keeps the caller's
166
+ decision instead.
167
+
168
+ ``before_send`` is called with each exception's and message's properties before they
169
+ are sent (after the client's own scrubbing). Return them, changed or not, or ``None``
170
+ to drop the event. If it raises, the event is dropped: a scrubbing hook that fails
171
+ must not let data through. The server scrubs again either way."""
172
+ if not api_key:
173
+ raise TypeError("api_key is required")
174
+ self._api_key = api_key
175
+ self._base_url = base_url.rstrip("/")
176
+ self._timeout = timeout_seconds
177
+ self._traces_sample_rate = min(1.0, max(0.0, float(traces_sample_rate)))
178
+ self._before_send = before_send
179
+ self._exposed: set[tuple[str, str, str, str]] = set()
180
+
181
+ def _send_error(self, workspace_id: str, product_id: str, properties: dict) -> dict:
182
+ """Exceptions and messages go through ``before_send`` first. A dropped event returns
183
+ ``{"accepted": False, "dropped": True}`` without a request."""
184
+ if self._before_send is not None:
185
+ try:
186
+ kept = self._before_send(properties)
187
+ except Exception: # noqa: BLE001 - a failing hook drops the event
188
+ kept = None
189
+ if kept is None:
190
+ return {"accepted": False, "dropped": True}
191
+ properties = kept
192
+ return self.ingest(workspace_id, product_id, "$exception", properties)
193
+
194
+ def start_transaction(self, workspace_id: str, product_id: str, name: str, op: str = "http.server",
195
+ traceparent: Optional[str] = None, release: Optional[str] = None,
196
+ environment: Optional[str] = None) -> Transaction:
197
+ """Start a transaction, as a context manager or finished with ``finish()``. Pass an
198
+ incoming ``traceparent`` header to continue a trace from another service (a browser
199
+ page load, a Node service); its sampling decision is kept."""
200
+ parent = _TRACEPARENT.match((traceparent or "").strip())
201
+ if parent:
202
+ trace_id, parent_span_id, sampled = parent.group(1), parent.group(2), (int(parent.group(3), 16) & 1) == 1
203
+ else:
204
+ trace_id, parent_span_id, sampled = os.urandom(16).hex(), None, random.random() < self._traces_sample_rate
205
+ return Transaction(self, workspace_id, product_id, name, op, trace_id, parent_span_id, sampled, release, environment)
206
+
207
+ def _request(self, path: str, body: Optional[dict] = None) -> Any:
208
+ request = urllib.request.Request(
209
+ f"{self._base_url}/api/v1{path}",
210
+ data=json.dumps(body or {}).encode("utf-8") if body is not None else None,
211
+ headers={
212
+ "accept": "application/json",
213
+ "authorization": f"Bearer {self._api_key}",
214
+ "content-type": "application/json",
215
+ },
216
+ method="POST" if body is not None else "GET",
217
+ )
218
+ try:
219
+ with urllib.request.urlopen(request, timeout=self._timeout) as response:
220
+ payload = response.read().decode("utf-8") or "{}"
221
+ try:
222
+ return json.loads(payload)
223
+ except ValueError:
224
+ # A proxy or error page can return non-JSON with a 200. Callers that
225
+ # fail closed need this to arrive as an ApphelmError, not a ValueError.
226
+ raise ApphelmError("Apphelm returned a response that was not JSON.", response.status or 0, "invalid_response")
227
+ except urllib.error.HTTPError as error:
228
+ try:
229
+ detail = json.loads(error.read().decode("utf-8") or "{}")
230
+ err = detail.get("error", {}) if isinstance(detail, dict) else {}
231
+ raise ApphelmError(str(err.get("message", f"Apphelm request failed ({error.code})")), error.code, str(err.get("code", "request_failed")))
232
+ except (ValueError, ApphelmError) as wrapped:
233
+ if isinstance(wrapped, ApphelmError):
234
+ raise
235
+ raise ApphelmError(f"Apphelm request failed ({error.code})", error.code)
236
+ except urllib.error.URLError as error:
237
+ # DNS failure, refused connection, TLS problem or timeout. Without this, a
238
+ # network blip escaped is_enabled's fail-closed guard and reached the caller.
239
+ raise ApphelmError(f"Apphelm could not be reached: {error.reason}", 0, "unreachable")
240
+ except TimeoutError as error:
241
+ raise ApphelmError(f"Apphelm request timed out: {error}", 0, "timeout")
242
+
243
+ def ingest(self, workspace_id: str, product_id: str, event_name: str, properties: Optional[dict] = None, occurred_at: Optional[str] = None, event_id: Optional[str] = None) -> dict:
244
+ """Send one event. It carries an event id (``event_id``, or a fresh one), so a retry
245
+ with the same id is stored and charged once."""
246
+ return self._request("/events", {
247
+ "workspaceId": workspace_id, "productId": product_id, "eventName": event_name,
248
+ "privacyMode": "strict", "properties": properties or {},
249
+ "eventId": event_id or uuid.uuid4().hex,
250
+ **({"occurredAt": occurred_at} if occurred_at else {}),
251
+ })
252
+
253
+ def capture(self, workspace_id: str, product_id: str, distinct_id: str, event_name: str, properties: Optional[dict] = None, occurred_at: Optional[str] = None) -> dict:
254
+ """Send a product event for one person, like PostHog's server ``capture``.
255
+
256
+ ``distinct_id`` is your user id, or whatever the browser SDK was given as
257
+ ``distinctId``. Apphelm hashes it at ingest the way the browser does, so the event
258
+ joins that person's browser events in funnels, retention, unique-people trends and
259
+ experiments. Without one a server event only ever counts as volume.
260
+ """
261
+ person = str(distinct_id).strip()
262
+ if not person:
263
+ raise ValueError("distinct_id is required to capture an event for a person; use ingest for anonymous volume.")
264
+ return self.ingest(workspace_id, product_id, event_name, {**(properties or {}), "distinct_id": person}, occurred_at)
265
+
266
+ def identify(
267
+ self,
268
+ workspace_id: str,
269
+ product_id: str,
270
+ external_user_id: str,
271
+ traits: Optional[dict] = None,
272
+ trait_sources: Optional[dict] = None,
273
+ ) -> dict:
274
+ """Identify a person.
275
+
276
+ Every trait needs explicit provenance: the API rejects a trait without a matching
277
+ ``trait_sources`` entry, so this raises rather than sending a request that cannot
278
+ succeed. A source entry looks like
279
+ ``{"sourceType": "auth", "sourceId": "session", "verification": "integration_verified"}``.
280
+ """
281
+ values = traits or {}
282
+ sources = trait_sources or {}
283
+ missing = sorted(key for key in values if key not in sources)
284
+ if missing:
285
+ raise ValueError(f"Every trait requires provenance in trait_sources. Missing: {', '.join(missing)}")
286
+ orphaned = sorted(key for key in sources if key not in values)
287
+ if orphaned:
288
+ raise ValueError(f"trait_sources has no matching trait value for: {', '.join(orphaned)}")
289
+ return self.ingest(workspace_id, product_id, "apphelm.identify", {
290
+ "externalUserId": external_user_id, "traits": values,
291
+ "traitSources": sources, "consent": [], "isAccountAdmin": False, "isChampion": False,
292
+ })
293
+
294
+ def evaluate_flags(self, product_id: str, distinct_id: str, flag_key: Optional[str] = None, environment: Optional[str] = None) -> list[FlagEvaluation]:
295
+ body: dict[str, Any] = {"productId": product_id, "distinctId": distinct_id}
296
+ if flag_key:
297
+ body["flagKey"] = flag_key
298
+ if environment:
299
+ body["environment"] = environment
300
+ data = self._request("/flags/evaluate", body)
301
+ return [FlagEvaluation(
302
+ key=str(row.get("key", "")), enabled=bool(row.get("enabled")),
303
+ variant=row.get("variant"), payload=row.get("payload"), reason=str(row.get("reason", "")),
304
+ ) for row in data.get("data", [])]
305
+
306
+ def is_enabled(self, flag_key: str, distinct_id: str, product_id: str, environment: Optional[str] = None) -> bool:
307
+ """Fail-closed: any transport or API failure returns False."""
308
+ try:
309
+ return any(row.key == flag_key and row.enabled for row in self.evaluate_flags(product_id, distinct_id, flag_key, environment))
310
+ except ApphelmError:
311
+ return False
312
+ except (TypeError, ValueError):
313
+ raise
314
+
315
+ def get_feature_flag(self, workspace_id: str, product_id: str, flag_key: str, distinct_id: str,
316
+ environment: Optional[str] = None, send_exposure: Optional[bool] = None) -> Any:
317
+ """The flag's variant (``True`` for an enabled boolean flag) or ``False``, fail-closed.
318
+
319
+ For a flag a running experiment depends on it also records the exposure the
320
+ experiment's results divide by; ``send_exposure`` forces that on or off. The
321
+ exposure never changes what is returned.
322
+ """
323
+ body: dict[str, Any] = {"productId": product_id, "distinctId": distinct_id, "flagKey": flag_key}
324
+ if environment:
325
+ body["environment"] = environment
326
+ try:
327
+ rows = self._request("/flags/evaluate", body).get("data", [])
328
+ except ApphelmError:
329
+ return False
330
+ row = next((item for item in rows if isinstance(item, dict) and item.get("key") == flag_key), None)
331
+ if row is None:
332
+ return False
333
+ if send_exposure if send_exposure is not None else row.get("experiment") is True:
334
+ try:
335
+ self.record_exposure(workspace_id, product_id, flag_key, distinct_id, row.get("variant"), bool(row.get("enabled")))
336
+ except ApphelmError:
337
+ pass
338
+ return (row.get("variant") or True) if row.get("enabled") else False
339
+
340
+ def record_exposure(self, workspace_id: str, product_id: str, flag_key: str, distinct_id: str,
341
+ variant: Optional[str], enabled: bool = True) -> Optional[dict]:
342
+ """Record that ``distinct_id`` was served a flag: the ``apphelm.flag_evaluated``
343
+ event experiment results count people from. ``get_feature_flag`` sends it for
344
+ experiment flags; call it yourself after evaluating some other way. Sent once per
345
+ flag, person and variant per client (the last 10,000 are remembered)."""
346
+ chosen = variant or ("on" if enabled else "off")
347
+ key = (product_id, flag_key, str(distinct_id), chosen)
348
+ if key in self._exposed:
349
+ return None
350
+ if len(self._exposed) >= 10_000:
351
+ self._exposed.clear()
352
+ self._exposed.add(key)
353
+ return self.ingest(workspace_id, product_id, "apphelm.flag_evaluated", {"flag_key": flag_key, "variant": chosen, "distinct_id": str(distinct_id)})
354
+
355
+ def capture_message(self, workspace_id: str, product_id: str, message: str, level: str = "info",
356
+ fingerprint: Optional[str | Sequence[str]] = None, release: Optional[str] = None,
357
+ environment: Optional[str] = None,
358
+ tags: Optional[dict[str, str | int | float | bool]] = None) -> dict:
359
+ """Report a message rather than an exception: something noteworthy with no error
360
+ object. It becomes an issue grouped by its text (numbers and ids normalised) or by
361
+ ``fingerprint``, with ``level`` (fatal, error, warning, info or debug) as a tag."""
362
+ if level not in ("fatal", "error", "warning", "info", "debug"):
363
+ raise ValueError("level must be fatal, error, warning, info or debug")
364
+ properties: dict[str, Any] = {"message": str(message)[:240], "name": "Message", "level": level}
365
+ if fingerprint is not None:
366
+ parts = [fingerprint] if isinstance(fingerprint, str) else list(fingerprint)
367
+ properties["fingerprint"] = [str(part)[:200] for part in parts][:10]
368
+ if release:
369
+ properties["release"] = str(release)[:120]
370
+ if environment:
371
+ properties["environment"] = str(environment)[:40]
372
+ if tags:
373
+ properties["tags"] = {str(key)[:40]: value for key, value in list(tags.items())[:20]}
374
+ return self._send_error(workspace_id, product_id, properties)
375
+
376
+ def check_in(self, product_id: str, monitor: str, status: str, check_in_id: Optional[str] = None,
377
+ duration_ms: Optional[int] = None, monitor_config: Optional[dict[str, Any]] = None) -> dict:
378
+ """Check in a cron job: ``in_progress`` at start, then ``ok`` or ``error`` with the
379
+ same ``check_in_id`` (any ``chk_`` id you generate, so retries are safe), or one
380
+ ``ok``/``error``, which closes the newest open run. ``monitor_config`` (``{"schedule": {"type":
381
+ "crontab", "value": "0 * * * *"}}``) creates or updates the monitor."""
382
+ if status not in ("in_progress", "ok", "error"):
383
+ raise ValueError("status must be in_progress, ok or error")
384
+ body: dict[str, Any] = {"productId": product_id, "monitor": monitor, "status": status}
385
+ if check_in_id:
386
+ body["checkInId"] = check_in_id
387
+ if duration_ms is not None:
388
+ body["durationMs"] = int(duration_ms)
389
+ if monitor_config:
390
+ body["monitorConfig"] = monitor_config
391
+ return self._request("/check-ins", body)
392
+
393
+ def capture_exception(self, workspace_id: str, product_id: str, error: BaseException | str,
394
+ fingerprint: Optional[str | Sequence[str]] = None,
395
+ release: Optional[str] = None, environment: Optional[str] = None,
396
+ tags: Optional[dict[str, str | int | float | bool]] = None,
397
+ handled: bool = True, user_id: Optional[str | int] = None,
398
+ trace_id: Optional[str] = None) -> dict:
399
+ """Report an exception. Apphelm groups it by type, message and top in-app frame, or by
400
+ ``fingerprint`` when given. The traceback is scrubbed of emails, keys and query strings.
401
+ ``release`` and ``environment`` show where the issue happens and let an issue resolved
402
+ in a release reopen only when a different release hits it.
403
+ ``user_id`` counts the person toward the issue's affected users; only its hash is sent.
404
+ ``trace_id`` (``transaction.trace_id``) links the issue to the trace it happened in."""
405
+ message = error.args[0] if isinstance(error, BaseException) and error.args else str(error)
406
+ properties: dict[str, Any] = {
407
+ "message": str(message)[:240], "name": type(error).__name__ if isinstance(error, BaseException) else "Error",
408
+ }
409
+ if isinstance(error, BaseException) and error.__traceback__ is not None:
410
+ properties["stack"] = _scrub_stack("".join(traceback.format_exception(type(error), error, error.__traceback__)))
411
+ if fingerprint is not None:
412
+ parts = [fingerprint] if isinstance(fingerprint, str) else list(fingerprint)
413
+ properties["fingerprint"] = [str(part)[:200] for part in parts][:10]
414
+ if release:
415
+ properties["release"] = str(release)[:120]
416
+ if environment:
417
+ properties["environment"] = str(environment)[:40]
418
+ if tags:
419
+ properties["tags"] = {str(key)[:40]: value for key, value in list(tags.items())[:20]}
420
+ if not handled:
421
+ properties["handled"] = False
422
+ if user_id is not None and str(user_id) != "":
423
+ # Counts the person toward the issue's affected users. Only the hash is sent,
424
+ # the same one the Node client and the server compute.
425
+ properties["user_hash"] = hashlib.sha256(f"apphelm-user:{str(user_id)[:200]}".encode("utf-8")).hexdigest()[:32]
426
+ if trace_id and re.fullmatch(r"[0-9a-f]{32}", trace_id):
427
+ properties["trace_id"] = trace_id
428
+ return self._send_error(workspace_id, product_id, properties)
429
+
430
+ def capture_unhandled(self, workspace_id: str, product_id: str, release: Optional[str] = None,
431
+ environment: Optional[str] = None,
432
+ tags: Optional[dict[str, str | int | float | bool]] = None) -> Callable[[], None]:
433
+ """Report uncaught exceptions, in the main thread (``sys.excepthook``) and in other
434
+ threads (``threading.excepthook``), as unhandled issues. The previous hooks still run
435
+ afterwards, so the traceback prints and the interpreter exits as it would have.
436
+ ``KeyboardInterrupt`` and ``SystemExit`` are not errors and are not reported. A failed
437
+ report never replaces the original exception. Returns a function that restores the
438
+ previous hooks."""
439
+ previous_sys = sys.excepthook
440
+ previous_thread = threading.excepthook
441
+
442
+ def report(error: BaseException) -> None:
443
+ try:
444
+ self.capture_exception(workspace_id, product_id, error, release=release, environment=environment, tags=tags, handled=False)
445
+ except Exception: # noqa: BLE001 - the original exception matters more
446
+ pass
447
+
448
+ def sys_hook(exc_type, exc, tb):
449
+ if exc is not None and not issubclass(exc_type, (KeyboardInterrupt, SystemExit)):
450
+ report(exc)
451
+ previous_sys(exc_type, exc, tb)
452
+
453
+ def thread_hook(args):
454
+ if args.exc_value is not None and not issubclass(args.exc_type, (KeyboardInterrupt, SystemExit)):
455
+ report(args.exc_value)
456
+ previous_thread(args)
457
+
458
+ sys.excepthook = sys_hook
459
+ threading.excepthook = thread_hook
460
+
461
+ def restore() -> None:
462
+ if sys.excepthook is sys_hook:
463
+ sys.excepthook = previous_sys
464
+ if threading.excepthook is thread_hook:
465
+ threading.excepthook = previous_thread
466
+
467
+ return restore
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "apphelm"
7
+ dynamic = ["version"]
8
+ description = "Official Python server SDK for Apphelm: events, identify, fail-closed feature flags, errors and tracing."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ # Same terms as the npm package (@sansavision/apphelm, "UNLICENSED" with LICENSE): proprietary,
12
+ # use under an Apphelm service agreement. The owner decides the published terms; see docs/SDK-RELEASING.md.
13
+ license = "LicenseRef-Proprietary"
14
+ license-files = ["LICENSE"]
15
+ authors = [{ name = "SansaVision", email = "support@sansavision.com" }]
16
+ keywords = ["apphelm", "analytics", "feature-flags", "error-monitoring", "tracing"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Topic :: Software Development :: Libraries",
24
+ "Topic :: System :: Monitoring",
25
+ ]
26
+ # Stdlib only: no runtime dependencies.
27
+ dependencies = []
28
+
29
+ # No Source link: the monorepo (Sansa-Organisation/apphelm) is private, so it would 404 for users.
30
+ [project.urls]
31
+ Homepage = "https://apphelm.dev"
32
+ Documentation = "https://apphelm.dev/docs"
33
+
34
+ [tool.hatch.version]
35
+ path = "apphelm.py"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ only-include = ["apphelm.py"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ only-include = ["apphelm.py", "test_apphelm.py", "README.md", "LICENSE", "pyproject.toml"]
@@ -0,0 +1,395 @@
1
+ """Stdlib-only tests for the Apphelm Python client. Run: python3 test_apphelm.py"""
2
+
3
+ import io
4
+ import json
5
+ import unittest
6
+ import urllib.error
7
+ from unittest.mock import patch
8
+
9
+ from apphelm import Apphelm, ApphelmError
10
+
11
+
12
+ class FakeResponse:
13
+ def __init__(self, body: dict, raw: bytes | None = None, status: int = 200):
14
+ self._body = raw if raw is not None else json.dumps(body).encode("utf-8")
15
+ self.status = status
16
+
17
+ def read(self) -> bytes:
18
+ return self._body
19
+
20
+ def __enter__(self):
21
+ return self
22
+
23
+ def __exit__(self, *args):
24
+ return False
25
+
26
+
27
+ def http_error(code: int, body: dict) -> urllib.error.HTTPError:
28
+ return urllib.error.HTTPError("https://apphelm.dev/api/v1/flags/evaluate", code, "error", {}, io.BytesIO(json.dumps(body).encode("utf-8")))
29
+
30
+
31
+ class ClientTest(unittest.TestCase):
32
+ def test_requires_api_key(self):
33
+ with self.assertRaises(TypeError):
34
+ Apphelm("")
35
+
36
+ def test_evaluate_flags_parses_evaluations(self):
37
+ client = Apphelm("ahl_live_test")
38
+ seen = {}
39
+
40
+ def fake_urlopen(request, timeout=None):
41
+ seen["url"] = request.full_url
42
+ seen["auth"] = request.get_header("Authorization")
43
+ return FakeResponse({"object": "flag_evaluations", "data": [{"key": "new-checkout", "enabled": True, "variant": "on", "payload": True, "reason": "enabled"}], "failClosed": True})
44
+
45
+ with patch("urllib.request.urlopen", fake_urlopen):
46
+ rows = client.evaluate_flags("prd_1", "user-1")
47
+ self.assertEqual(seen["url"], "https://apphelm.dev/api/v1/flags/evaluate")
48
+ self.assertEqual(seen["auth"], "Bearer ahl_live_test")
49
+ self.assertTrue(rows[0].enabled)
50
+
51
+ def test_is_enabled_fails_closed_on_api_error(self):
52
+ client = Apphelm("ahl_live_test")
53
+
54
+ def fake_urlopen(request, timeout=None):
55
+ raise http_error(503, {"error": {"code": "unavailable", "message": "down"}})
56
+
57
+ with patch("urllib.request.urlopen", fake_urlopen):
58
+ self.assertFalse(client.is_enabled("new-checkout", "user-1", "prd_1"))
59
+
60
+ def test_is_enabled_fails_closed_when_apphelm_is_unreachable(self):
61
+ # A network failure used to escape as urllib.error.URLError, defeating the
62
+ # fail-closed guarantee and breaking the caller's request instead.
63
+ client = Apphelm("ahl_live_test")
64
+
65
+ def fake_urlopen(request, timeout=None):
66
+ raise urllib.error.URLError("dns failure")
67
+
68
+ with patch("urllib.request.urlopen", fake_urlopen):
69
+ self.assertFalse(client.is_enabled("new-checkout", "user-1", "prd_1"))
70
+
71
+ def test_is_enabled_fails_closed_on_timeout(self):
72
+ client = Apphelm("ahl_live_test")
73
+
74
+ def fake_urlopen(request, timeout=None):
75
+ raise TimeoutError("timed out")
76
+
77
+ with patch("urllib.request.urlopen", fake_urlopen):
78
+ self.assertFalse(client.is_enabled("new-checkout", "user-1", "prd_1"))
79
+
80
+ def test_is_enabled_fails_closed_on_non_json_response(self):
81
+ client = Apphelm("ahl_live_test")
82
+
83
+ def fake_urlopen(request, timeout=None):
84
+ return FakeResponse({}, raw=b"<html>proxy error</html>")
85
+
86
+ with patch("urllib.request.urlopen", fake_urlopen):
87
+ self.assertFalse(client.is_enabled("new-checkout", "user-1", "prd_1"))
88
+
89
+ def test_identify_requires_trait_provenance(self):
90
+ # The API rejects a trait without a matching source, so the client refuses to send
91
+ # a request that cannot succeed.
92
+ client = Apphelm("ahl_live_test")
93
+ with self.assertRaises(ValueError) as caught:
94
+ client.identify("org_1", "prd_1", "usr_1", traits={"plan": "builder"})
95
+ self.assertIn("plan", str(caught.exception))
96
+
97
+ with self.assertRaises(ValueError):
98
+ client.identify("org_1", "prd_1", "usr_1", trait_sources={"plan": {"sourceType": "auth"}})
99
+
100
+ def test_identify_sends_traits_with_provenance(self):
101
+ client = Apphelm("ahl_live_test")
102
+ seen = {}
103
+
104
+ def fake_urlopen(request, timeout=None):
105
+ seen["body"] = json.loads(request.data.decode("utf-8"))
106
+ return FakeResponse({"accepted": True, "eventId": "evt_1"})
107
+
108
+ source = {"sourceType": "auth", "sourceId": "session", "verification": "integration_verified"}
109
+ with patch("urllib.request.urlopen", fake_urlopen):
110
+ client.identify("org_1", "prd_1", "usr_1", traits={"plan": "builder"}, trait_sources={"plan": source})
111
+ self.assertEqual(seen["body"]["properties"]["traits"], {"plan": "builder"})
112
+ self.assertEqual(seen["body"]["properties"]["traitSources"], {"plan": source})
113
+
114
+ def test_identify_without_traits_still_works(self):
115
+ client = Apphelm("ahl_live_test")
116
+
117
+ def fake_urlopen(request, timeout=None):
118
+ return FakeResponse({"accepted": True, "eventId": "evt_1"})
119
+
120
+ with patch("urllib.request.urlopen", fake_urlopen):
121
+ self.assertTrue(client.identify("org_1", "prd_1", "usr_1")["accepted"])
122
+
123
+ def test_capture_sends_the_person_as_distinct_id(self):
124
+ client = Apphelm("ahl_live_test")
125
+ seen = {}
126
+
127
+ def fake_urlopen(request, timeout=None):
128
+ seen["url"] = request.full_url
129
+ seen["body"] = json.loads(request.data.decode("utf-8"))
130
+ return FakeResponse({"accepted": True, "eventId": "evt_1"})
131
+
132
+ with patch("urllib.request.urlopen", fake_urlopen):
133
+ client.capture("org_1", "prd_1", " usr_42 ", "subscription_paid", {"plan": "builder"})
134
+ self.assertEqual(seen["url"], "https://apphelm.dev/api/v1/events")
135
+ self.assertEqual(seen["body"]["eventName"], "subscription_paid")
136
+ self.assertEqual(seen["body"]["properties"], {"plan": "builder", "distinct_id": "usr_42"})
137
+
138
+ def test_ingest_sends_an_event_id_so_retries_are_stored_once(self):
139
+ client = Apphelm("ahl_live_test")
140
+ bodies = []
141
+
142
+ def fake_urlopen(request, timeout=None):
143
+ bodies.append(json.loads(request.data.decode("utf-8")))
144
+ return FakeResponse({"accepted": True, "eventId": "evt_1"})
145
+
146
+ with patch("urllib.request.urlopen", fake_urlopen):
147
+ client.ingest("org_1", "prd_1", "server_event")
148
+ client.ingest("org_1", "prd_1", "server_event", event_id="caller-chosen-1")
149
+ self.assertRegex(bodies[0]["eventId"], r"^[0-9a-f]{32}$")
150
+ self.assertEqual(bodies[1]["eventId"], "caller-chosen-1")
151
+
152
+ def test_capture_refuses_an_empty_distinct_id(self):
153
+ with self.assertRaises(ValueError):
154
+ Apphelm("ahl_live_test").capture("org_1", "prd_1", " ", "subscription_paid")
155
+
156
+ def test_get_feature_flag_records_one_exposure_for_an_experiment_flag(self):
157
+ client = Apphelm("ahl_live_test")
158
+ exposures = []
159
+
160
+ def fake_urlopen(request, timeout=None):
161
+ body = json.loads(request.data.decode("utf-8"))
162
+ if request.full_url.endswith("/flags/evaluate"):
163
+ experiment = body["flagKey"] == "new-checkout"
164
+ return FakeResponse({"object": "flag_evaluations", "failClosed": True, "data": [{"key": body["flagKey"], "enabled": True, "variant": "test" if experiment else "on", "payload": None, "reason": "variant", **({"experiment": True} if experiment else {})}]})
165
+ if body["eventName"] == "apphelm.flag_evaluated":
166
+ exposures.append(body)
167
+ return FakeResponse({"accepted": True, "eventId": "evt_1"})
168
+
169
+ with patch("urllib.request.urlopen", fake_urlopen):
170
+ self.assertEqual(client.get_feature_flag("org_1", "prd_1", "new-checkout", "usr_1"), "test")
171
+ self.assertEqual(client.get_feature_flag("org_1", "prd_1", "new-checkout", "usr_1"), "test")
172
+ self.assertEqual(client.get_feature_flag("org_1", "prd_1", "dark-mode", "usr_1"), "on")
173
+ self.assertEqual(len(exposures), 1)
174
+ self.assertEqual(exposures[0]["workspaceId"], "org_1")
175
+ self.assertEqual(exposures[0]["properties"], {"flag_key": "new-checkout", "variant": "test", "distinct_id": "usr_1"})
176
+
177
+ def test_get_feature_flag_fails_closed_without_recording(self):
178
+ client = Apphelm("ahl_live_test")
179
+ calls = []
180
+
181
+ def fake_urlopen(request, timeout=None):
182
+ calls.append(request.full_url)
183
+ raise http_error(503, {"error": {"code": "unavailable", "message": "down"}})
184
+
185
+ with patch("urllib.request.urlopen", fake_urlopen):
186
+ self.assertIs(client.get_feature_flag("org_1", "prd_1", "new-checkout", "usr_1"), False)
187
+ self.assertEqual(len(calls), 1)
188
+
189
+ def test_capture_exception_posts_scrubbed_message(self):
190
+ client = Apphelm("ahl_live_test")
191
+ seen = {}
192
+
193
+ def fake_urlopen(request, timeout=None):
194
+ seen["body"] = json.loads(request.data.decode("utf-8"))
195
+ return FakeResponse({"accepted": True, "eventId": "evt_1"})
196
+
197
+ with patch("urllib.request.urlopen", fake_urlopen):
198
+ client.capture_exception("org_1", "prd_1", ValueError("boom"))
199
+ self.assertEqual(seen["body"]["eventName"], "$exception")
200
+ self.assertEqual(seen["body"]["properties"]["message"], "boom")
201
+
202
+ def test_capture_exception_sends_scrubbed_traceback_and_fingerprint(self):
203
+ client = Apphelm("ahl_live_test")
204
+ seen = {}
205
+
206
+ def fake_urlopen(request, timeout=None):
207
+ seen["body"] = json.loads(request.data.decode("utf-8"))
208
+ return FakeResponse({"accepted": True, "eventId": "evt_1"})
209
+
210
+ try:
211
+ raise ValueError("failed for someone@example.com")
212
+ except ValueError as error:
213
+ with patch("urllib.request.urlopen", fake_urlopen):
214
+ client.capture_exception("org_1", "prd_1", error, fingerprint=["billing", "timeout"], release="3.1.0", environment="production", tags={"region": "eu"})
215
+ properties = seen["body"]["properties"]
216
+ self.assertEqual(properties["fingerprint"], ["billing", "timeout"])
217
+ self.assertIn('File "', properties["stack"])
218
+ self.assertIn("test_capture_exception_sends_scrubbed_traceback_and_fingerprint", properties["stack"])
219
+ self.assertNotIn("someone@example.com", properties["stack"])
220
+ self.assertEqual(properties["release"], "3.1.0")
221
+ self.assertEqual(properties["environment"], "production")
222
+ self.assertEqual(properties["tags"], {"region": "eu"})
223
+
224
+ def test_capture_unhandled_reports_then_runs_previous_hooks_and_restores_them(self):
225
+ import sys
226
+ import threading
227
+ client = Apphelm("ahl_live_test")
228
+ sent = []
229
+ chained = []
230
+
231
+ def fake_urlopen(request, timeout=None):
232
+ sent.append(json.loads(request.data.decode("utf-8")))
233
+ return FakeResponse({"accepted": True, "eventId": "evt_1"})
234
+
235
+ original_sys, original_thread = sys.excepthook, threading.excepthook
236
+ sys.excepthook = lambda *args: chained.append("sys")
237
+ threading.excepthook = lambda args: chained.append("thread")
238
+ try:
239
+ restore = client.capture_unhandled("org_1", "prd_1", release="3.1.0", environment="production")
240
+ with patch("urllib.request.urlopen", fake_urlopen):
241
+ try:
242
+ raise RuntimeError("crashed")
243
+ except RuntimeError as error:
244
+ sys.excepthook(type(error), error, error.__traceback__)
245
+ sys.excepthook(KeyboardInterrupt, KeyboardInterrupt(), None)
246
+ worker = threading.Thread(target=lambda: (_ for _ in ()).throw(ValueError("in a thread")))
247
+ worker.start()
248
+ worker.join()
249
+ self.assertEqual([body["properties"]["message"] for body in sent], ["crashed", "in a thread"])
250
+ self.assertEqual({body["properties"]["handled"] for body in sent}, {False})
251
+ self.assertEqual(sent[0]["properties"]["release"], "3.1.0")
252
+ self.assertIn("crashed", sent[0]["properties"]["stack"])
253
+ # The previous hooks still run, including for the interrupt that was not reported.
254
+ self.assertEqual(chained, ["sys", "sys", "thread"])
255
+ restore()
256
+ self.assertEqual(chained, ["sys", "sys", "thread"])
257
+ sys.excepthook(RuntimeError, RuntimeError("after restore"), None)
258
+ self.assertEqual(len(sent), 2)
259
+ finally:
260
+ sys.excepthook, threading.excepthook = original_sys, original_thread
261
+
262
+ def test_capture_unhandled_survives_a_failed_report(self):
263
+ import sys
264
+ client = Apphelm("ahl_live_test")
265
+ chained = []
266
+
267
+ def failing_urlopen(request, timeout=None):
268
+ raise urllib.error.URLError("offline")
269
+
270
+ original_sys = sys.excepthook
271
+ sys.excepthook = lambda *args: chained.append(args[1])
272
+ try:
273
+ restore = client.capture_unhandled("org_1", "prd_1")
274
+ error = RuntimeError("crashed")
275
+ with patch("urllib.request.urlopen", failing_urlopen):
276
+ sys.excepthook(RuntimeError, error, None)
277
+ self.assertEqual(chained, [error])
278
+ restore()
279
+ finally:
280
+ sys.excepthook = original_sys
281
+
282
+ def test_capture_message_sends_level_and_no_stack(self):
283
+ client = Apphelm("ahl_live_test")
284
+ seen = {}
285
+
286
+ def fake_urlopen(request, timeout=None):
287
+ seen["body"] = json.loads(request.data.decode("utf-8"))
288
+ return FakeResponse({"accepted": True, "eventId": "evt_1"})
289
+
290
+ with patch("urllib.request.urlopen", fake_urlopen):
291
+ client.capture_message("org_1", "prd_1", "Payment provider fell back to retry", level="warning", release="3.1.0")
292
+ properties = seen["body"]["properties"]
293
+ self.assertEqual(seen["body"]["eventName"], "$exception")
294
+ self.assertEqual(properties["name"], "Message")
295
+ self.assertEqual(properties["level"], "warning")
296
+ self.assertEqual(properties["release"], "3.1.0")
297
+ self.assertNotIn("stack", properties)
298
+ with self.assertRaises(ValueError):
299
+ client.capture_message("org_1", "prd_1", "x", level="loud")
300
+
301
+ def test_transaction_sends_spans_and_continues_an_incoming_trace(self):
302
+ client = Apphelm("ahl_live_test", traces_sample_rate=0.0)
303
+ sent = []
304
+
305
+ def fake_urlopen(request, timeout=None):
306
+ sent.append(json.loads(request.data.decode("utf-8")))
307
+ return FakeResponse({"accepted": True, "eventId": "evt_1"})
308
+
309
+ incoming = "00-" + "a" * 32 + "-" + "b" * 16 + "-01"
310
+ with patch("urllib.request.urlopen", fake_urlopen):
311
+ with client.start_transaction("org_1", "prd_1", "GET /orders/1841", traceparent=incoming, release="3.1.0") as transaction:
312
+ with transaction.start_span("db.query", "select order"):
313
+ pass
314
+ self.assertEqual(transaction.traceparent(), f"00-{'a' * 32}-{transaction.span_id}-01")
315
+ # Sampled out locally (rate 0, no incoming trace): nothing is sent.
316
+ with client.start_transaction("org_1", "prd_1", "job") as unsampled:
317
+ self.assertFalse(unsampled.sampled)
318
+ try:
319
+ with client.start_transaction("org_1", "prd_1", "failing", traceparent=incoming):
320
+ raise RuntimeError("boom")
321
+ except RuntimeError:
322
+ pass
323
+ self.assertEqual(len(sent), 2)
324
+ properties = sent[0]["properties"]
325
+ self.assertEqual(sent[0]["eventName"], "$transaction")
326
+ self.assertEqual(properties["trace_id"], "a" * 32)
327
+ self.assertEqual(properties["parent_span_id"], "b" * 16)
328
+ self.assertEqual(properties["status"], "ok")
329
+ self.assertEqual(properties["release"], "3.1.0")
330
+ spans = json.loads(properties["spans"])
331
+ self.assertEqual([(span["op"], span["description"], span["parent_span_id"]) for span in spans], [("db.query", "select order", properties["span_id"])])
332
+ self.assertEqual(sent[1]["properties"]["status"], "error")
333
+
334
+ def test_transaction_send_failure_never_raises_and_exceptions_link_to_traces(self):
335
+ client = Apphelm("ahl_live_test")
336
+ seen = {}
337
+
338
+ def failing_urlopen(request, timeout=None):
339
+ raise http_error(503, {"error": {"code": "unavailable", "message": "down"}})
340
+
341
+ with patch("urllib.request.urlopen", failing_urlopen):
342
+ self.assertIsNone(client.start_transaction("org_1", "prd_1", "job").finish())
343
+
344
+ def fake_urlopen(request, timeout=None):
345
+ seen["body"] = json.loads(request.data.decode("utf-8"))
346
+ return FakeResponse({"accepted": True, "eventId": "evt_1"})
347
+
348
+ with patch("urllib.request.urlopen", fake_urlopen):
349
+ client.capture_exception("org_1", "prd_1", ValueError("x"), trace_id="c" * 32)
350
+ self.assertEqual(seen["body"]["properties"]["trace_id"], "c" * 32)
351
+ client.capture_exception("org_1", "prd_1", ValueError("x"), trace_id="not-a-trace")
352
+ self.assertNotIn("trace_id", seen["body"]["properties"])
353
+
354
+ def test_before_send_can_change_or_drop_errors_and_fails_closed(self):
355
+ sent = []
356
+
357
+ def fake_urlopen(request, timeout=None):
358
+ sent.append(json.loads(request.data.decode("utf-8")))
359
+ return FakeResponse({"accepted": True, "eventId": "evt_1"})
360
+
361
+ def hook(properties):
362
+ if "drop me" in properties["message"]:
363
+ return None
364
+ if "explode" in properties["message"]:
365
+ raise RuntimeError("hook bug")
366
+ return {**properties, "message": properties["message"].replace("Acme", "[customer]")}
367
+
368
+ client = Apphelm("ahl_live_test", before_send=hook)
369
+ with patch("urllib.request.urlopen", fake_urlopen):
370
+ client.capture_exception("org_1", "prd_1", ValueError("Acme checkout failed"))
371
+ self.assertEqual(client.capture_exception("org_1", "prd_1", ValueError("drop me")), {"accepted": False, "dropped": True})
372
+ self.assertEqual(client.capture_message("org_1", "prd_1", "explode"), {"accepted": False, "dropped": True})
373
+ client.capture_message("org_1", "prd_1", "Acme is slow", level="warning")
374
+ client.ingest("org_1", "prd_1", "purchase_completed", {"note": "drop me"})
375
+ self.assertEqual([body["properties"].get("message") for body in sent], ["[customer] checkout failed", "[customer] is slow", None])
376
+
377
+ def test_check_in_posts_monitor_status_and_config(self):
378
+ client = Apphelm("ahl_live_test")
379
+ seen = {}
380
+
381
+ def fake_urlopen(request, timeout=None):
382
+ seen["url"] = request.full_url
383
+ seen["body"] = json.loads(request.data.decode("utf-8"))
384
+ return FakeResponse({"object": "check_in", "data": {"id": "chk_1", "monitorId": "mon_1", "status": "ok"}})
385
+
386
+ with patch("urllib.request.urlopen", fake_urlopen):
387
+ client.check_in("prd_1", "billing", "ok", duration_ms=1200, monitor_config={"schedule": {"type": "crontab", "value": "0 * * * *"}})
388
+ self.assertTrue(seen["url"].endswith("/api/v1/check-ins"))
389
+ self.assertEqual(seen["body"], {"productId": "prd_1", "monitor": "billing", "status": "ok", "durationMs": 1200, "monitorConfig": {"schedule": {"type": "crontab", "value": "0 * * * *"}}})
390
+ with self.assertRaises(ValueError):
391
+ client.check_in("prd_1", "billing", "done")
392
+
393
+
394
+ if __name__ == "__main__":
395
+ unittest.main()