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.
- apifyhub_sentinel-0.3.0/.gitignore +4 -0
- apifyhub_sentinel-0.3.0/PKG-INFO +135 -0
- apifyhub_sentinel-0.3.0/README.md +123 -0
- apifyhub_sentinel-0.3.0/pyproject.toml +25 -0
- apifyhub_sentinel-0.3.0/src/apifyhub_sentinel/__init__.py +470 -0
- apifyhub_sentinel-0.3.0/src/apifyhub_sentinel/_env.py +68 -0
- apifyhub_sentinel-0.3.0/src/apifyhub_sentinel/_fingerprint.py +132 -0
- apifyhub_sentinel-0.3.0/src/apifyhub_sentinel/_input.py +75 -0
- apifyhub_sentinel-0.3.0/src/apifyhub_sentinel/_source.py +50 -0
- apifyhub_sentinel-0.3.0/src/apifyhub_sentinel/_transport.py +86 -0
|
@@ -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
|