apifyhub-sentinel 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.5
2
+ Name: apifyhub-sentinel
3
+ Version: 0.3.0
4
+ Summary: Drop-in error tracking for Apify actors. One wrapper: with sentinel(apify_hub_key=...).
5
+ Project-URL: Homepage, https://apifyhub.com
6
+ Project-URL: Documentation, https://apifyhub.com/features/sentinel
7
+ Author: apifyhub
8
+ License: MIT
9
+ Keywords: actor,apify,error-tracking,monitoring,sentinel
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+
13
+ # apifyhub-sentinel
14
+
15
+ Drop-in **error tracking** for [Apify](https://apify.com) actors — the Python
16
+ port of [`@apifyhub/sentinel`](../js). It captures exceptions, **groups them
17
+ into issues** by fingerprint, carries breadcrumbs, the run's input and the full
18
+ Apify run context (run/actor/build ids, consumer, origin), and reports each to
19
+ your apifyHub dashboard.
20
+
21
+ Both ports ship as one product under **one version number** and produce
22
+ identical fingerprints, so the same bug raised from a JS actor and a Python
23
+ actor lands in the same issue.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install apifyhub-sentinel
29
+ ```
30
+
31
+ Requires Python 3.9+. Zero runtime dependencies (stdlib `urllib` only).
32
+
33
+ ## Usage
34
+
35
+ Wrap your actor body — any raise is captured, grouped, flushed, and re-raised
36
+ unchanged:
37
+
38
+ ```python
39
+ import os
40
+ from apifyhub_sentinel import sentinel
41
+
42
+ with sentinel(apify_hub_key=os.environ["APIFYHUB_KEY"]):
43
+ ... # the actor's normal code
44
+ ```
45
+
46
+ Or wire it manually for finer control:
47
+
48
+ ```python
49
+ from apifyhub_sentinel import init, capture_exception, add_breadcrumb, flush
50
+
51
+ init(apify_hub_key=os.environ["APIFYHUB_KEY"])
52
+
53
+ add_breadcrumb(category="http", message="GET /search", level="info")
54
+
55
+ try:
56
+ do_work()
57
+ except Exception as exc:
58
+ capture_exception(exc) # grouped into an issue by fingerprint
59
+ flush() # ensure delivery before the container exits
60
+ raise
61
+ ```
62
+
63
+ ### Setup
64
+
65
+ 1. Generate an **apifyHub key** on your [apifyhub.com](https://apifyhub.com) profile.
66
+ 2. Add it as a **secret env var** named `APIFYHUB_KEY` (Apify Console → your actor →
67
+ Settings → Environment variables).
68
+ 3. Deploy. Crashes and captured errors now show up as issues on your dashboard.
69
+
70
+ ## API
71
+
72
+ | Function | Purpose |
73
+ | --- | --- |
74
+ | `init(apify_hub_key, **opts)` | Initialise; installs global excepthooks (unless `capture_unhandled=False`). Idempotent. |
75
+ | `sentinel(apify_hub_key, **opts)` | Context manager: init + capture/flush/re-raise around the body. |
76
+ | `capture_exception(exc, ...)` | Report an exception. Returns the event id. |
77
+ | `capture_message(msg, ...)` | Report a plain message at a given `level`. |
78
+ | `add_breadcrumb(...)` | Append a timeline entry shown with the next error. |
79
+ | `set_tag(k, v)` / `set_context(name, obj)` / `set_user(...)` | Attach metadata to subsequent events. |
80
+ | `flush(timeout_s=2.0)` | Await in-flight sends. Returns whether it drained in time. |
81
+
82
+ ### Options
83
+
84
+ Every option `init()` takes is also accepted by `sentinel()`:
85
+
86
+ | Option | Default | Meaning |
87
+ | --- | --- | --- |
88
+ | `apify_hub_key` | required | Bearer token; the server SHA-256 hashes it and matches the owner's hash. |
89
+ | `endpoint` | production | Override the ingest URL. |
90
+ | `debug` | `False` | Print internal failures to stderr. |
91
+ | `force_outside_apify` | `False` | Send even when `APIFY_IS_AT_HOME != "1"` (local testing). |
92
+ | `capture_unhandled` | `True` | Install `sys.excepthook` / `threading.excepthook` handlers. |
93
+ | `capture_input` | `True` | Fetch the run's INPUT record and attach it as `contexts.input`. |
94
+ | `sample_rate` | `1.0` | Drop a fraction of events client-side. |
95
+ | `max_breadcrumbs` | `50` | Ring-buffer size per scope. |
96
+ | `release` / `environment` | `None` | Free-form labels carried on every event. |
97
+ | `before_send` | `None` | Last-chance hook to scrub or drop an event (return `None` to drop). |
98
+
99
+ ## Input capture
100
+
101
+ The run's `INPUT` record is fetched once at init on a background thread and
102
+ attached to every event as `contexts.input`, so you can see what the actor was
103
+ asked to do when it broke. Notes:
104
+
105
+ - **Secret input fields stay encrypted.** Only the Apify SDK's `get_input()`
106
+ decrypts them, so no plaintext secrets reach the dashboard.
107
+ - Oversized inputs (>16KB) are truncated.
108
+ - Best-effort: a crash before the fetch resolves ships without input.
109
+ - Turn it off with `capture_input=False`.
110
+
111
+ ## Differences from the JS port
112
+
113
+ Behaviour is deliberately identical; only the idioms differ.
114
+
115
+ - `with sentinel(...)` replaces `await withSentinel(options, fn)`.
116
+ - Options are snake_case keyword arguments rather than an options object.
117
+ - `flush()` is synchronous and returns `bool` (no `await`).
118
+ - Uncaught errors are caught via `sys.excepthook` and `threading.excepthook`.
119
+ Both chain to the previous hook, so the traceback still prints and the process
120
+ still exits non-zero — matching the JS port, which restores Node's default
121
+ crash semantics by hand.
122
+ - Stack frames come from `traceback.extract_tb`, which is already newest-last,
123
+ so unlike the JS port there is no reversal step.
124
+
125
+ ## Tests
126
+
127
+ ```bash
128
+ ./run_tests.sh
129
+ ```
130
+
131
+ Runs the unit tests, then the **cross-port parity check**: `tests/check_parity.mjs`
132
+ computes fingerprints with the JS source and `tests/compare_parity.py` asserts
133
+ the Python port produces the same 8-char hex for every case. That check is the
134
+ thing that keeps one bug from splitting into two issues on the dashboard, so run
135
+ it after touching `_fingerprint.py` or the JS `fingerprint.ts`.
@@ -0,0 +1,123 @@
1
+ # apifyhub-sentinel
2
+
3
+ Drop-in **error tracking** for [Apify](https://apify.com) actors — the Python
4
+ port of [`@apifyhub/sentinel`](../js). It captures exceptions, **groups them
5
+ into issues** by fingerprint, carries breadcrumbs, the run's input and the full
6
+ Apify run context (run/actor/build ids, consumer, origin), and reports each to
7
+ your apifyHub dashboard.
8
+
9
+ Both ports ship as one product under **one version number** and produce
10
+ identical fingerprints, so the same bug raised from a JS actor and a Python
11
+ actor lands in the same issue.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install apifyhub-sentinel
17
+ ```
18
+
19
+ Requires Python 3.9+. Zero runtime dependencies (stdlib `urllib` only).
20
+
21
+ ## Usage
22
+
23
+ Wrap your actor body — any raise is captured, grouped, flushed, and re-raised
24
+ unchanged:
25
+
26
+ ```python
27
+ import os
28
+ from apifyhub_sentinel import sentinel
29
+
30
+ with sentinel(apify_hub_key=os.environ["APIFYHUB_KEY"]):
31
+ ... # the actor's normal code
32
+ ```
33
+
34
+ Or wire it manually for finer control:
35
+
36
+ ```python
37
+ from apifyhub_sentinel import init, capture_exception, add_breadcrumb, flush
38
+
39
+ init(apify_hub_key=os.environ["APIFYHUB_KEY"])
40
+
41
+ add_breadcrumb(category="http", message="GET /search", level="info")
42
+
43
+ try:
44
+ do_work()
45
+ except Exception as exc:
46
+ capture_exception(exc) # grouped into an issue by fingerprint
47
+ flush() # ensure delivery before the container exits
48
+ raise
49
+ ```
50
+
51
+ ### Setup
52
+
53
+ 1. Generate an **apifyHub key** on your [apifyhub.com](https://apifyhub.com) profile.
54
+ 2. Add it as a **secret env var** named `APIFYHUB_KEY` (Apify Console → your actor →
55
+ Settings → Environment variables).
56
+ 3. Deploy. Crashes and captured errors now show up as issues on your dashboard.
57
+
58
+ ## API
59
+
60
+ | Function | Purpose |
61
+ | --- | --- |
62
+ | `init(apify_hub_key, **opts)` | Initialise; installs global excepthooks (unless `capture_unhandled=False`). Idempotent. |
63
+ | `sentinel(apify_hub_key, **opts)` | Context manager: init + capture/flush/re-raise around the body. |
64
+ | `capture_exception(exc, ...)` | Report an exception. Returns the event id. |
65
+ | `capture_message(msg, ...)` | Report a plain message at a given `level`. |
66
+ | `add_breadcrumb(...)` | Append a timeline entry shown with the next error. |
67
+ | `set_tag(k, v)` / `set_context(name, obj)` / `set_user(...)` | Attach metadata to subsequent events. |
68
+ | `flush(timeout_s=2.0)` | Await in-flight sends. Returns whether it drained in time. |
69
+
70
+ ### Options
71
+
72
+ Every option `init()` takes is also accepted by `sentinel()`:
73
+
74
+ | Option | Default | Meaning |
75
+ | --- | --- | --- |
76
+ | `apify_hub_key` | required | Bearer token; the server SHA-256 hashes it and matches the owner's hash. |
77
+ | `endpoint` | production | Override the ingest URL. |
78
+ | `debug` | `False` | Print internal failures to stderr. |
79
+ | `force_outside_apify` | `False` | Send even when `APIFY_IS_AT_HOME != "1"` (local testing). |
80
+ | `capture_unhandled` | `True` | Install `sys.excepthook` / `threading.excepthook` handlers. |
81
+ | `capture_input` | `True` | Fetch the run's INPUT record and attach it as `contexts.input`. |
82
+ | `sample_rate` | `1.0` | Drop a fraction of events client-side. |
83
+ | `max_breadcrumbs` | `50` | Ring-buffer size per scope. |
84
+ | `release` / `environment` | `None` | Free-form labels carried on every event. |
85
+ | `before_send` | `None` | Last-chance hook to scrub or drop an event (return `None` to drop). |
86
+
87
+ ## Input capture
88
+
89
+ The run's `INPUT` record is fetched once at init on a background thread and
90
+ attached to every event as `contexts.input`, so you can see what the actor was
91
+ asked to do when it broke. Notes:
92
+
93
+ - **Secret input fields stay encrypted.** Only the Apify SDK's `get_input()`
94
+ decrypts them, so no plaintext secrets reach the dashboard.
95
+ - Oversized inputs (>16KB) are truncated.
96
+ - Best-effort: a crash before the fetch resolves ships without input.
97
+ - Turn it off with `capture_input=False`.
98
+
99
+ ## Differences from the JS port
100
+
101
+ Behaviour is deliberately identical; only the idioms differ.
102
+
103
+ - `with sentinel(...)` replaces `await withSentinel(options, fn)`.
104
+ - Options are snake_case keyword arguments rather than an options object.
105
+ - `flush()` is synchronous and returns `bool` (no `await`).
106
+ - Uncaught errors are caught via `sys.excepthook` and `threading.excepthook`.
107
+ Both chain to the previous hook, so the traceback still prints and the process
108
+ still exits non-zero — matching the JS port, which restores Node's default
109
+ crash semantics by hand.
110
+ - Stack frames come from `traceback.extract_tb`, which is already newest-last,
111
+ so unlike the JS port there is no reversal step.
112
+
113
+ ## Tests
114
+
115
+ ```bash
116
+ ./run_tests.sh
117
+ ```
118
+
119
+ Runs the unit tests, then the **cross-port parity check**: `tests/check_parity.mjs`
120
+ computes fingerprints with the JS source and `tests/compare_parity.py` asserts
121
+ the Python port produces the same 8-char hex for every case. That check is the
122
+ thing that keeps one bug from splitting into two issues on the dashboard, so run
123
+ it after touching `_fingerprint.py` or the JS `fingerprint.ts`.
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "apifyhub-sentinel"
7
+ # Kept in lockstep with @apifyhub/sentinel: the two ports ship as one product.
8
+ version = "0.3.0"
9
+ description = "Drop-in error tracking for Apify actors. One wrapper: with sentinel(apify_hub_key=...)."
10
+ readme = "README.md"
11
+ requires-python = ">=3.9"
12
+ license = { text = "MIT" }
13
+ authors = [{ name = "apifyhub" }]
14
+ dependencies = []
15
+ keywords = ["apify", "error-tracking", "monitoring", "actor", "sentinel"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://apifyhub.com"
19
+ Documentation = "https://apifyhub.com/features/sentinel"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/apifyhub_sentinel"]
23
+
24
+ [tool.hatch.build.targets.sdist]
25
+ include = ["src/apifyhub_sentinel", "README.md"]
@@ -0,0 +1,470 @@
1
+ """apifyhub-sentinel — drop-in error tracking for Apify actors.
2
+
3
+ The Python port of `@apifyhub/sentinel`. Same wire format, same grouping, same
4
+ dashboard:
5
+
6
+ from apifyhub_sentinel import sentinel
7
+
8
+ with sentinel(apify_hub_key=os.environ["APIFYHUB_KEY"]):
9
+ ... # the actor's normal code. Any raise is captured, grouped,
10
+ # flushed, and re-raised unchanged.
11
+
12
+ Or wire it manually:
13
+
14
+ from apifyhub_sentinel import init, capture_exception, add_breadcrumb, flush
15
+
16
+ init(apify_hub_key=os.environ["APIFYHUB_KEY"])
17
+ try:
18
+ do_work()
19
+ except Exception as exc:
20
+ capture_exception(exc) # grouped into an issue by fingerprint
21
+ flush() # ensure delivery before the container exits
22
+ raise
23
+
24
+ Hard rules (shared with @apifyhub/analytics and the JS sentinel SDK):
25
+ 1. Never raise. A bad key, network outage, or non-Apify environment must
26
+ never crash the host actor or swallow its error.
27
+ 2. The key travels as `Authorization: Bearer`, never in the body.
28
+ 3. Idempotent init; calling it twice in a process is a no-op after the first.
29
+ 4. Zero runtime deps. Uses urllib from the stdlib.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import sys
34
+ import threading
35
+ import time
36
+ import uuid
37
+ from contextlib import contextmanager
38
+ from typing import Any, Callable, Dict, Iterator, List, Optional
39
+
40
+ from ._env import EnvSnapshot, read_env
41
+ from ._fingerprint import compute_fingerprint, parse_stack
42
+ from ._input import fetch_actor_input
43
+ from ._source import attach_source_context
44
+ from ._transport import flush as _transport_flush
45
+ from ._transport import send_event
46
+
47
+ __all__ = [
48
+ "sentinel",
49
+ "init",
50
+ "capture_exception",
51
+ "capture_message",
52
+ "add_breadcrumb",
53
+ "set_tag",
54
+ "set_context",
55
+ "set_user",
56
+ "flush",
57
+ "VERSION",
58
+ ]
59
+
60
+ # Kept in lockstep with sdk/sentinel/js/src/index.ts — the two ports ship as
61
+ # one product and are documented with a single version number.
62
+ VERSION = "0.3.0"
63
+
64
+ _DEFAULT_ENDPOINT = "https://notable-toad-601.convex.site/v1/sentinel-ingest"
65
+
66
+ _SEVERITIES = ("fatal", "error", "warning", "info", "debug")
67
+
68
+
69
+ class _Scope:
70
+ """Mutable per-process scope: tags/contexts/user/breadcrumbs on events."""
71
+
72
+ def __init__(self) -> None:
73
+ self.tags: Dict[str, str] = {}
74
+ self.contexts: Dict[str, Dict[str, Any]] = {}
75
+ self.user: Optional[Dict[str, Any]] = None
76
+ self.breadcrumbs: List[Dict[str, Any]] = []
77
+ self.lock = threading.Lock()
78
+
79
+
80
+ class _Client:
81
+ def __init__(
82
+ self, options: Dict[str, Any], endpoint: str, env: EnvSnapshot
83
+ ) -> None:
84
+ self.options = options
85
+ self.endpoint = endpoint
86
+ self.env = env
87
+ self.scope = _Scope()
88
+
89
+
90
+ # Module-level singleton, mirroring the JS port's implicit global hub.
91
+ _client: Optional[_Client] = None
92
+ _client_lock = threading.Lock()
93
+
94
+
95
+ def _current() -> Optional[_Client]:
96
+ return _client
97
+
98
+
99
+ def init(
100
+ apify_hub_key: str,
101
+ *,
102
+ endpoint: Optional[str] = None,
103
+ debug: bool = False,
104
+ force_outside_apify: bool = False,
105
+ capture_unhandled: bool = True,
106
+ capture_input: bool = True,
107
+ sample_rate: float = 1.0,
108
+ max_breadcrumbs: int = 50,
109
+ release: Optional[str] = None,
110
+ environment: Optional[str] = None,
111
+ before_send: Optional[Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]] = None,
112
+ ) -> None:
113
+ """Initialise the SDK.
114
+
115
+ Safe to call anywhere; returns silently (no-op) on a missing key, outside
116
+ Apify, or on a second call. Installs global excepthooks unless
117
+ `capture_unhandled=False`.
118
+ """
119
+ global _client
120
+ try:
121
+ if not apify_hub_key or not isinstance(apify_hub_key, str):
122
+ if debug:
123
+ print("[sentinel] missing apify_hub_key — no-op", file=sys.stderr)
124
+ return
125
+ env = read_env()
126
+ if not env.is_at_home and not force_outside_apify:
127
+ if debug:
128
+ print(
129
+ "[sentinel] APIFY_IS_AT_HOME!=1 — no-op "
130
+ "(pass force_outside_apify to override)",
131
+ file=sys.stderr,
132
+ )
133
+ return
134
+
135
+ with _client_lock:
136
+ if _client is not None:
137
+ if debug:
138
+ print("[sentinel] already initialised — skipping", file=sys.stderr)
139
+ return
140
+ options: Dict[str, Any] = {
141
+ "apify_hub_key": apify_hub_key,
142
+ "debug": debug,
143
+ "sample_rate": sample_rate,
144
+ "max_breadcrumbs": max_breadcrumbs,
145
+ "release": release,
146
+ "environment": environment,
147
+ "before_send": before_send,
148
+ }
149
+ client = _Client(options, endpoint or _DEFAULT_ENDPOINT, env)
150
+ if env.user_id:
151
+ client.scope.user = {
152
+ "id": env.user_id,
153
+ "isPaying": env.user_is_paying,
154
+ }
155
+ _client = client
156
+
157
+ if capture_unhandled:
158
+ _install_global_handlers(client)
159
+
160
+ if capture_input:
161
+
162
+ def _on_input(payload: Dict[str, Any]) -> None:
163
+ # Don't clobber an input the caller already set via set_context.
164
+ with client.scope.lock:
165
+ if "input" not in client.scope.contexts:
166
+ client.scope.contexts["input"] = payload
167
+
168
+ fetch_actor_input(env, _on_input)
169
+
170
+ if debug:
171
+ print(
172
+ f"[sentinel] initialised run={env.run_id} "
173
+ f"actor={env.actor_full_name or env.actor_id}",
174
+ file=sys.stderr,
175
+ )
176
+ except Exception:
177
+ # A bug in init() must never crash the host actor.
178
+ pass
179
+
180
+
181
+ def set_tag(key: str, value: str) -> None:
182
+ """Attach a key/value tag to every subsequent event."""
183
+ try:
184
+ c = _current()
185
+ if c and isinstance(key, str):
186
+ with c.scope.lock:
187
+ c.scope.tags[key] = str(value)
188
+ except Exception:
189
+ pass
190
+
191
+
192
+ def set_context(name: str, ctx: Dict[str, Any]) -> None:
193
+ """Attach a named context block (e.g. "input", "proxy") to later events."""
194
+ try:
195
+ c = _current()
196
+ if c and isinstance(name, str):
197
+ with c.scope.lock:
198
+ c.scope.contexts[name] = ctx
199
+ except Exception:
200
+ pass
201
+
202
+
203
+ def set_user(
204
+ *, id: Optional[str] = None, is_paying: Optional[bool] = None
205
+ ) -> None:
206
+ """Identify the consumer. Defaults to the Apify user id when available."""
207
+ try:
208
+ c = _current()
209
+ if c:
210
+ with c.scope.lock:
211
+ c.scope.user = {"id": id, "isPaying": is_paying}
212
+ except Exception:
213
+ pass
214
+
215
+
216
+ def add_breadcrumb(
217
+ *,
218
+ message: Optional[str] = None,
219
+ category: Optional[str] = None,
220
+ level: Optional[str] = None,
221
+ data: Optional[Dict[str, Any]] = None,
222
+ timestamp: Optional[int] = None,
223
+ ) -> None:
224
+ """Record a timeline entry shown alongside the error that follows.
225
+
226
+ Oldest are dropped past `max_breadcrumbs` (default 50). No-op before init.
227
+ """
228
+ try:
229
+ c = _current()
230
+ if not c:
231
+ return
232
+ crumb: Dict[str, Any] = {"timestamp": timestamp or _now_ms()}
233
+ if category is not None:
234
+ crumb["category"] = category
235
+ if message is not None:
236
+ crumb["message"] = message
237
+ if level is not None:
238
+ crumb["level"] = level
239
+ if data is not None:
240
+ crumb["data"] = data
241
+ max_crumbs = c.options.get("max_breadcrumbs") or 50
242
+ with c.scope.lock:
243
+ c.scope.breadcrumbs.append(crumb)
244
+ if len(c.scope.breadcrumbs) > max_crumbs:
245
+ del c.scope.breadcrumbs[: len(c.scope.breadcrumbs) - max_crumbs]
246
+ except Exception:
247
+ pass
248
+
249
+
250
+ def capture_exception(
251
+ error: BaseException,
252
+ *,
253
+ level: str = "error",
254
+ handled: bool = True,
255
+ tags: Optional[Dict[str, str]] = None,
256
+ ) -> Optional[str]:
257
+ """Report an exception. Returns the event id, or None if dropped."""
258
+ try:
259
+ c = _current()
260
+ if not c:
261
+ return None
262
+ exc_type = type(error).__name__ if error is not None else "Error"
263
+ message = str(error) if error is not None else ""
264
+ frames = parse_stack(getattr(error, "__traceback__", None))
265
+ attach_source_context(frames)
266
+ event = _base_event(c, _severity(level), handled)
267
+ event["exception"] = {
268
+ "type": exc_type,
269
+ "value": message,
270
+ "stacktrace": frames,
271
+ }
272
+ event["fingerprint"] = compute_fingerprint(exc_type, message, frames)
273
+ if tags:
274
+ event["tags"] = {**event["tags"], **tags}
275
+ return _dispatch(c, event)
276
+ except Exception:
277
+ return None
278
+
279
+
280
+ def capture_message(
281
+ message: str,
282
+ *,
283
+ level: str = "info",
284
+ tags: Optional[Dict[str, str]] = None,
285
+ ) -> Optional[str]:
286
+ """Report a plain message. Grouped by normalized text."""
287
+ try:
288
+ c = _current()
289
+ if not c:
290
+ return None
291
+ event = _base_event(c, _severity(level), True)
292
+ event["message"] = message
293
+ event["fingerprint"] = compute_fingerprint("Message", message, [])
294
+ if tags:
295
+ event["tags"] = {**event["tags"], **tags}
296
+ return _dispatch(c, event)
297
+ except Exception:
298
+ return None
299
+
300
+
301
+ def flush(timeout_s: float = 2.0) -> bool:
302
+ """Await in-flight sends. True if everything drained in time."""
303
+ return _transport_flush(timeout_s)
304
+
305
+
306
+ @contextmanager
307
+ def sentinel(apify_hub_key: str, **kwargs: Any) -> Iterator[None]:
308
+ """Wrap the actor body so any raise is captured, flushed and re-raised.
309
+
310
+ Accepts every keyword `init()` does. A hard kill (OOM/timeout/abort)
311
+ bypasses the handler, so those crashes are only caught when the global
312
+ excepthooks were installed (the default).
313
+ """
314
+ init(apify_hub_key, **kwargs)
315
+ try:
316
+ yield
317
+ except BaseException as exc: # noqa: BLE001 - re-raised untouched below
318
+ try:
319
+ capture_exception(exc, level="fatal", handled=False)
320
+ flush()
321
+ except Exception:
322
+ # Reporting must never change the actor's outcome.
323
+ pass
324
+ raise
325
+ else:
326
+ # Flush on the clean path too: any handled capture_exception still in
327
+ # flight would be lost when the container exits.
328
+ try:
329
+ flush()
330
+ except Exception:
331
+ pass
332
+
333
+
334
+ # ---- internals ------------------------------------------------------------
335
+
336
+
337
+ def _now_ms() -> int:
338
+ return int(time.time() * 1000)
339
+
340
+
341
+ def _severity(level: Optional[str]) -> str:
342
+ return level if level in _SEVERITIES else "error"
343
+
344
+
345
+ def _base_event(c: _Client, level: str, handled: bool) -> Dict[str, Any]:
346
+ env = c.env
347
+ with c.scope.lock:
348
+ # Snapshot so later additions don't mutate a queued event.
349
+ breadcrumbs = list(c.scope.breadcrumbs)
350
+ tags = dict(c.scope.tags)
351
+ contexts = dict(c.scope.contexts)
352
+ user = dict(c.scope.user) if c.scope.user else None
353
+ event: Dict[str, Any] = {
354
+ "eventType": "SENTINEL.ERROR",
355
+ "apifyHubSdkLanguage": "python",
356
+ "apifyHubSdkVersion": VERSION,
357
+ "eventId": _event_id(),
358
+ "timestamp": _now_ms(),
359
+ "level": level,
360
+ "fingerprint": "",
361
+ "handled": handled,
362
+ "breadcrumbs": breadcrumbs,
363
+ "tags": tags,
364
+ "contexts": contexts,
365
+ }
366
+ if user:
367
+ event["user"] = user
368
+ for key, value in (
369
+ ("release", c.options.get("release")),
370
+ ("environment", c.options.get("environment")),
371
+ ("runId", env.run_id),
372
+ ("actorId", env.actor_id),
373
+ ("actorFullName", env.actor_full_name),
374
+ ("buildId", env.build_id),
375
+ ("buildNumber", env.build_number),
376
+ ("taskId", env.task_id),
377
+ ("origin", env.origin),
378
+ ):
379
+ if value is not None:
380
+ event[key] = value
381
+ return event
382
+
383
+
384
+ def _dispatch(c: _Client, event: Dict[str, Any]) -> Optional[str]:
385
+ """Apply sampling + before_send, then hand to the transport."""
386
+ configured = c.options.get("sample_rate", 1.0)
387
+ try:
388
+ rate = float(configured)
389
+ if rate != rate: # NaN
390
+ rate = 1.0
391
+ except (TypeError, ValueError):
392
+ rate = 1.0
393
+ rate = max(0.0, min(1.0, rate))
394
+ if rate < 1.0 and _hash_unit(event["eventId"]) >= rate:
395
+ return None
396
+
397
+ out: Optional[Dict[str, Any]] = event
398
+ before_send = c.options.get("before_send")
399
+ if before_send:
400
+ try:
401
+ out = before_send(event)
402
+ except Exception:
403
+ out = event # a raising hook must not drop the event
404
+ if not out:
405
+ return None
406
+
407
+ send_event(
408
+ c.endpoint, c.options["apify_hub_key"], out, bool(c.options.get("debug"))
409
+ )
410
+ return out.get("eventId")
411
+
412
+
413
+ def _install_global_handlers(c: _Client) -> None:
414
+ """Report uncaught exceptions, then defer to the previous hook.
415
+
416
+ Chaining to the previous hook (rather than replacing it) preserves the
417
+ interpreter's default behaviour: the traceback still prints and the process
418
+ still exits non-zero, which is what the JS port goes out of its way to
419
+ restore.
420
+ """
421
+ try:
422
+ previous_hook = sys.excepthook
423
+
424
+ def hook(exc_type, exc_value, exc_tb): # type: ignore[no-untyped-def]
425
+ try:
426
+ if exc_value is not None:
427
+ capture_exception(exc_value, level="fatal", handled=False)
428
+ flush()
429
+ except Exception:
430
+ pass # never mask the original crash with our own
431
+ previous_hook(exc_type, exc_value, exc_tb)
432
+
433
+ sys.excepthook = hook
434
+
435
+ # Uncaught exceptions on non-main threads go through a separate hook.
436
+ previous_thread_hook = getattr(threading, "excepthook", None)
437
+ if previous_thread_hook is not None:
438
+
439
+ def thread_hook(args): # type: ignore[no-untyped-def]
440
+ try:
441
+ if args.exc_value is not None:
442
+ capture_exception(
443
+ args.exc_value, level="fatal", handled=False
444
+ )
445
+ flush()
446
+ except Exception:
447
+ pass
448
+ previous_thread_hook(args)
449
+
450
+ threading.excepthook = thread_hook
451
+
452
+ if c.options.get("debug"):
453
+ print("[sentinel] global handlers installed", file=sys.stderr)
454
+ except Exception:
455
+ pass
456
+
457
+
458
+ def _event_id() -> str:
459
+ try:
460
+ return uuid.uuid4().hex
461
+ except Exception:
462
+ return format(_now_ms(), "x").rjust(16, "0")
463
+
464
+
465
+ def _hash_unit(event_id: str) -> float:
466
+ """Map an id to a stable [0,1) for deterministic sampling."""
467
+ h = 0
468
+ for ch in event_id:
469
+ h = (h * 31 + ord(ch)) & 0xFFFFFFFF
470
+ return h / 0xFFFFFFFF
@@ -0,0 +1,68 @@
1
+ """Apify run-context snapshot.
2
+
3
+ Mirrors `sdk/sentinel/js/src/env.ts` field for field so both SDKs put the same
4
+ run context on the wire. Kept as its own copy (rather than importing from the
5
+ analytics package) so this package stays zero-dependency and the two SDKs can
6
+ version independently.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Dict, NamedTuple, Optional
12
+
13
+
14
+ class EnvSnapshot(NamedTuple):
15
+ is_at_home: bool
16
+ run_id: Optional[str] = None
17
+ actor_id: Optional[str] = None
18
+ actor_full_name: Optional[str] = None
19
+ build_id: Optional[str] = None
20
+ build_number: Optional[str] = None
21
+ task_id: Optional[str] = None
22
+ user_id: Optional[str] = None
23
+ user_is_paying: Optional[bool] = None
24
+ started_at: Optional[str] = None
25
+ origin: Optional[str] = None
26
+ # For fetching the run's INPUT record (input capture).
27
+ default_key_value_store_id: Optional[str] = None
28
+ input_key: Optional[str] = None
29
+ token: Optional[str] = None
30
+ api_base_url: Optional[str] = None
31
+
32
+
33
+ def _first(e: Dict[str, str], *names: str) -> Optional[str]:
34
+ """First non-empty value among `names`, matching the JS `??` chains."""
35
+ for n in names:
36
+ v = e.get(n)
37
+ if v:
38
+ return v
39
+ return None
40
+
41
+
42
+ def _parse_bool(v: Optional[str]) -> Optional[bool]:
43
+ if v is None or v == "":
44
+ return None
45
+ return v == "1" or v.lower() == "true"
46
+
47
+
48
+ def read_env() -> EnvSnapshot:
49
+ e = dict(os.environ)
50
+ return EnvSnapshot(
51
+ is_at_home=e.get("APIFY_IS_AT_HOME") == "1",
52
+ run_id=_first(e, "ACTOR_RUN_ID", "APIFY_ACTOR_RUN_ID"),
53
+ actor_id=_first(e, "ACTOR_ID", "APIFY_ACTOR_ID"),
54
+ actor_full_name=_first(e, "ACTOR_FULL_NAME"),
55
+ build_id=_first(e, "ACTOR_BUILD_ID", "APIFY_ACTOR_BUILD_ID"),
56
+ build_number=_first(e, "ACTOR_BUILD_NUMBER"),
57
+ task_id=_first(e, "ACTOR_TASK_ID", "APIFY_ACTOR_TASK_ID"),
58
+ user_id=_first(e, "APIFY_USER_ID"),
59
+ user_is_paying=_parse_bool(e.get("APIFY_USER_IS_PAYING")),
60
+ started_at=_first(e, "ACTOR_STARTED_AT", "APIFY_STARTED_AT"),
61
+ origin=_first(e, "APIFY_META_ORIGIN"),
62
+ default_key_value_store_id=_first(
63
+ e, "ACTOR_DEFAULT_KEY_VALUE_STORE_ID", "APIFY_DEFAULT_KEY_VALUE_STORE_ID"
64
+ ),
65
+ input_key=_first(e, "ACTOR_INPUT_KEY", "APIFY_INPUT_KEY"),
66
+ token=_first(e, "APIFY_TOKEN"),
67
+ api_base_url=_first(e, "APIFY_API_PUBLIC_BASE_URL"),
68
+ )
@@ -0,0 +1,132 @@
1
+ """Error grouping, ported from `sdk/sentinel/js/src/fingerprint.ts`.
2
+
3
+ The hash algorithm and the string fed into it must stay byte-identical to the
4
+ JS SDK: the server buckets events into issues purely by this key, so a drift
5
+ between the two ports would split one bug into two issues.
6
+
7
+ Exceptions with a stack hash as: exception type + the in-app frame chain
8
+ (filename:function), message excluded, like Sentry. Stackless events
9
+ (capture_message) hash the normalized message instead, with volatile tokens
10
+ replaced by placeholders so "Timeout after 314ms" and "Timeout after 2200ms"
11
+ share a bucket.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ import sysconfig
18
+ import traceback
19
+ from types import TracebackType
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ # Frames from these trees are not "in app" — the equivalent of the JS port's
23
+ # node_modules / node: checks.
24
+ _VENDOR_MARKERS = (
25
+ os.sep + "site-packages" + os.sep,
26
+ os.sep + "dist-packages" + os.sep,
27
+ os.sep + "lib-dynload" + os.sep,
28
+ )
29
+
30
+
31
+ def _stdlib_dirs() -> List[str]:
32
+ dirs = []
33
+ for key in ("stdlib", "platstdlib"):
34
+ try:
35
+ p = sysconfig.get_paths().get(key)
36
+ except Exception:
37
+ p = None
38
+ if p:
39
+ dirs.append(os.path.normpath(p) + os.sep)
40
+ return dirs
41
+
42
+
43
+ _STDLIB_DIRS = _stdlib_dirs()
44
+
45
+
46
+ def is_in_app(filename: Optional[str]) -> bool:
47
+ if not filename:
48
+ return False
49
+ norm = os.path.normpath(filename)
50
+ if any(m in norm for m in _VENDOR_MARKERS):
51
+ return False
52
+ # A stdlib path is only vendor code if it is not also site-packages, which
53
+ # the check above already excluded.
54
+ if any(norm.startswith(d) for d in _STDLIB_DIRS):
55
+ return False
56
+ return True
57
+
58
+
59
+ def parse_stack(tb: Optional[TracebackType]) -> List[Dict[str, Any]]:
60
+ """Traceback -> Sentry-shaped frames, newest LAST.
61
+
62
+ `traceback.extract_tb` already yields outermost-first / innermost-last,
63
+ which is the order the dashboard expects, so unlike the JS port (which
64
+ reverses V8's newest-first stack) there is nothing to flip here.
65
+ """
66
+ frames: List[Dict[str, Any]] = []
67
+ if tb is None:
68
+ return frames
69
+ try:
70
+ for fs in traceback.extract_tb(tb):
71
+ frames.append(
72
+ {
73
+ "function": fs.name or None,
74
+ "filename": fs.filename,
75
+ "lineno": fs.lineno,
76
+ "in_app": is_in_app(fs.filename),
77
+ }
78
+ )
79
+ except Exception:
80
+ return frames
81
+ return frames
82
+
83
+
84
+ _UUID_RE = re.compile(
85
+ r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", re.I
86
+ )
87
+ _URL_RE = re.compile(r"\bhttps?://\S+", re.I)
88
+ _HEX_RE = re.compile(r"\b0x[0-9a-f]+\b", re.I)
89
+ # No word boundaries: a number glued to a unit ("314ms") must still normalize.
90
+ _NUM_RE = re.compile(r"\d[\d.,]*")
91
+ _STR_RE = re.compile(r"(['\"]).*?\1")
92
+ _WS_RE = re.compile(r"\s+")
93
+
94
+
95
+ def normalize_message(msg: str) -> str:
96
+ """Replace run-to-run-volatile tokens so similar messages group together."""
97
+ out = _UUID_RE.sub("<uuid>", msg)
98
+ out = _URL_RE.sub("<url>", out)
99
+ out = _HEX_RE.sub("<hex>", out)
100
+ out = _NUM_RE.sub("<num>", out)
101
+ out = _STR_RE.sub("<str>", out)
102
+ out = _WS_RE.sub(" ", out)
103
+ return out.strip()
104
+
105
+
106
+ def fnv1a(text: str) -> str:
107
+ """FNV-1a 32-bit -> 8-char hex.
108
+
109
+ The JS port hashes UTF-16 code units (`charCodeAt`), so this iterates the
110
+ same units rather than bytes or code points: for any string containing
111
+ astral characters, hashing code points would diverge from JS.
112
+ """
113
+ h = 0x811C9DC5
114
+ data = text.encode("utf-16-le")
115
+ for i in range(0, len(data), 2):
116
+ code_unit = data[i] | (data[i + 1] << 8)
117
+ h ^= code_unit
118
+ h = (h * 0x01000193) & 0xFFFFFFFF
119
+ return format(h, "08x")
120
+
121
+
122
+ def compute_fingerprint(
123
+ exc_type: str, message: str, frames: List[Dict[str, Any]]
124
+ ) -> str:
125
+ in_app = [f for f in frames if f.get("in_app")]
126
+ chosen = in_app if in_app else frames[-1:]
127
+ chain = "\n".join(
128
+ f"{f.get('filename')}:{f.get('function') or '?'}" for f in chosen
129
+ )
130
+ if not chain:
131
+ return fnv1a(f"{exc_type}\n{normalize_message(message)}")
132
+ return fnv1a(f"{exc_type}\n{chain}")
@@ -0,0 +1,75 @@
1
+ """Actor input capture, ported from `sdk/sentinel/js/src/input.ts`.
2
+
3
+ The run's INPUT record lives in its default key-value store, always readable
4
+ from inside the container with the run's own token, so we fetch it once at init
5
+ on a background thread and attach it to the scope as `contexts.input`, where
6
+ every subsequent event picks it up.
7
+
8
+ Notes:
9
+ - Best-effort: an error before the fetch resolves ships without input.
10
+ - Secret input fields arrive still encrypted (only the Apify SDK's get_input()
11
+ decrypts them), so no plaintext secrets reach the dashboard.
12
+ - Payload-capped: oversized inputs are stored as a truncated string.
13
+ - Never raises, same as the rest of the SDK.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import threading
19
+ import urllib.parse
20
+ import urllib.request
21
+ from typing import Any, Callable, Dict
22
+
23
+ from ._env import EnvSnapshot
24
+
25
+ MAX_INPUT_CHARS = 16_384
26
+ FETCH_TIMEOUT_S = 5.0
27
+
28
+
29
+ def fetch_actor_input(
30
+ env: EnvSnapshot, on_ready: Callable[[Dict[str, Any]], None]
31
+ ) -> None:
32
+ """Kick off a background fetch of the run's INPUT record."""
33
+ try:
34
+ if not env.default_key_value_store_id or not env.token:
35
+ return
36
+ t = threading.Thread(target=_fetch, args=(env, on_ready), daemon=True)
37
+ t.start()
38
+ except Exception:
39
+ # Never crash the host actor.
40
+ pass
41
+
42
+
43
+ def _fetch(env: EnvSnapshot, on_ready: Callable[[Dict[str, Any]], None]) -> None:
44
+ try:
45
+ base = env.api_base_url or "https://api.apify.com"
46
+ key = env.input_key or "INPUT"
47
+ url = (
48
+ f"{base}/v2/key-value-stores/{env.default_key_value_store_id}"
49
+ f"/records/{urllib.parse.quote(key, safe='')}"
50
+ )
51
+ req = urllib.request.Request(
52
+ url, headers={"Authorization": f"Bearer {env.token}"}
53
+ )
54
+ with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT_S) as res:
55
+ if not (200 <= res.status < 300):
56
+ return
57
+ raw = res.read()
58
+ if not raw:
59
+ return
60
+ text = raw.decode("utf-8", errors="replace")
61
+ if len(text) > MAX_INPUT_CHARS:
62
+ on_ready({"truncated": True, "raw": text[:MAX_INPUT_CHARS]})
63
+ return
64
+ try:
65
+ parsed = json.loads(text)
66
+ except Exception:
67
+ on_ready({"raw": text}) # non-JSON input (e.g. plain text record)
68
+ return
69
+ if isinstance(parsed, dict):
70
+ on_ready(parsed)
71
+ else:
72
+ on_ready({"value": parsed})
73
+ except Exception:
74
+ # Unreachable API / timeout — events simply ship without input.
75
+ pass
@@ -0,0 +1,50 @@
1
+ """Source context, ported from `sdk/sentinel/js/src/context.ts`.
2
+
3
+ For each in-app frame, attach the crash line plus/minus CONTEXT_LINES so the
4
+ dashboard can show code without the repo or source maps. Actors run their own
5
+ source from the container filesystem, so the file is right there at capture
6
+ time. `linecache` is stdlib and already caches per file.
7
+
8
+ Same hard rules as the rest of the SDK: never raise, and stay cheap.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import linecache
13
+ from typing import Any, Dict, List
14
+
15
+ CONTEXT_LINES = 5 # lines above and below the crash line
16
+ MAX_FRAMES = 8 # newest in-app frames that get context
17
+ MAX_LINE_CHARS = 300 # truncate beyond this
18
+
19
+
20
+ def _clip(line: str) -> str:
21
+ line = line.rstrip("\n")
22
+ return line if len(line) <= MAX_LINE_CHARS else line[:MAX_LINE_CHARS] + "…"
23
+
24
+
25
+ def attach_source_context(frames: List[Dict[str, Any]]) -> None:
26
+ """Mutate `frames` (newest-last), attaching context to in-app frames."""
27
+ try:
28
+ attached = 0
29
+ for f in reversed(frames):
30
+ if attached >= MAX_FRAMES:
31
+ break
32
+ filename = f.get("filename")
33
+ lineno = f.get("lineno")
34
+ if not f.get("in_app") or not filename or not lineno or lineno < 1:
35
+ continue
36
+ lines = linecache.getlines(filename)
37
+ if not lines or lineno > len(lines):
38
+ continue
39
+ at = lineno - 1
40
+ f["pre_context"] = [
41
+ _clip(x) for x in lines[max(0, at - CONTEXT_LINES) : at]
42
+ ]
43
+ f["context_line"] = _clip(lines[at])
44
+ f["post_context"] = [
45
+ _clip(x) for x in lines[at + 1 : at + 1 + CONTEXT_LINES]
46
+ ]
47
+ attached += 1
48
+ except Exception:
49
+ # Context is best-effort decoration; the event ships without it.
50
+ pass
@@ -0,0 +1,86 @@
1
+ """Wire transport, ported from `sdk/sentinel/js/src/transport.ts`.
2
+
3
+ Same contract as the JS sender: stdlib urllib, Bearer key in the header (never
4
+ the body), a hard timeout, and it never raises. Sends run on daemon threads so
5
+ capture never blocks the actor, with an in-flight registry so `flush()` can
6
+ await delivery before the container is torn down.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ import threading
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+ from typing import Any, Dict, List
17
+
18
+ _TIMEOUT_S = 5.0
19
+
20
+ _lock = threading.Lock()
21
+ _in_flight: List[threading.Thread] = []
22
+
23
+
24
+ def send_event(
25
+ endpoint: str, apify_hub_key: str, event: Dict[str, Any], debug: bool
26
+ ) -> None:
27
+ """Queue one event for delivery. Returns immediately; never raises."""
28
+ try:
29
+ body = json.dumps(event, default=str).encode("utf-8")
30
+ except Exception as exc: # pragma: no cover - json failure is pathological
31
+ if debug:
32
+ print(f"[sentinel] could not serialise event: {exc}", file=sys.stderr)
33
+ return
34
+
35
+ t = threading.Thread(
36
+ target=_post, args=(endpoint, apify_hub_key, body, debug), daemon=True
37
+ )
38
+ with _lock:
39
+ _in_flight.append(t)
40
+ # Drop threads that already finished so the list cannot grow unbounded
41
+ # over a long run.
42
+ _in_flight[:] = [x for x in _in_flight if x.is_alive() or x is t]
43
+ t.start()
44
+
45
+
46
+ def _post(endpoint: str, apify_hub_key: str, body: bytes, debug: bool) -> None:
47
+ req = urllib.request.Request(
48
+ endpoint,
49
+ data=body,
50
+ method="POST",
51
+ headers={
52
+ "content-type": "application/json",
53
+ "authorization": f"Bearer {apify_hub_key}",
54
+ },
55
+ )
56
+ try:
57
+ with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as res:
58
+ if debug and not (200 <= res.status < 300):
59
+ print(f"[sentinel] ingest HTTP {res.status}", file=sys.stderr)
60
+ except urllib.error.HTTPError as exc:
61
+ if debug:
62
+ print(f"[sentinel] ingest HTTP {exc.code}", file=sys.stderr)
63
+ except Exception as exc:
64
+ if debug:
65
+ print(f"[sentinel] ingest failed: {exc}", file=sys.stderr)
66
+
67
+
68
+ def flush(timeout_s: float = 2.0) -> bool:
69
+ """Await in-flight sends, bounded by `timeout_s`.
70
+
71
+ Returns True if everything drained in time, False otherwise. Never raises.
72
+ """
73
+ try:
74
+ with _lock:
75
+ pending = [t for t in _in_flight if t.is_alive()]
76
+ if not pending:
77
+ return True
78
+ deadline = time.monotonic() + timeout_s
79
+ for t in pending:
80
+ remaining = deadline - time.monotonic()
81
+ if remaining <= 0:
82
+ break
83
+ t.join(timeout=remaining)
84
+ return not any(t.is_alive() for t in pending)
85
+ except Exception:
86
+ return False