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.
- apphelm-0.5.0.dist-info/METADATA +108 -0
- apphelm-0.5.0.dist-info/RECORD +5 -0
- apphelm-0.5.0.dist-info/WHEEL +4 -0
- apphelm-0.5.0.dist-info/licenses/LICENSE +3 -0
- apphelm.py +467 -0
|
@@ -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,,
|
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
|