apphelm 0.5.0__py3-none-any.whl

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,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,5 @@
1
+ apphelm.py,sha256=PyRFeTzIjEP86ZRlIDSXI-v5ES9sSN2b-K9HWMo1uqU,24128
2
+ apphelm-0.5.0.dist-info/METADATA,sha256=5XOHhsuIfpiBpmiudySjNYN0JUuF-VfQx2h_dwN2bRY,4169
3
+ apphelm-0.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
4
+ apphelm-0.5.0.dist-info/licenses/LICENSE,sha256=SqrJtXJHUj3W671E2qinl87jGgbbmqwecIe1VawmUwQ,256
5
+ apphelm-0.5.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.py ADDED
@@ -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