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.
- apphelm-0.5.0/.gitignore +5 -0
- apphelm-0.5.0/LICENSE +3 -0
- apphelm-0.5.0/PKG-INFO +108 -0
- apphelm-0.5.0/README.md +88 -0
- apphelm-0.5.0/apphelm.py +467 -0
- apphelm-0.5.0/pyproject.toml +41 -0
- apphelm-0.5.0/test_apphelm.py +395 -0
apphelm-0.5.0/.gitignore
ADDED
apphelm-0.5.0/LICENSE
ADDED
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.
|
apphelm-0.5.0/README.md
ADDED
|
@@ -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.
|
apphelm-0.5.0/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
|
|
@@ -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()
|