sentinel-probes 0.1.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.
Files changed (26) hide show
  1. sentinel_probes-0.1.0/PKG-INFO +127 -0
  2. sentinel_probes-0.1.0/README.md +107 -0
  3. sentinel_probes-0.1.0/pyproject.toml +38 -0
  4. sentinel_probes-0.1.0/sentinel_probes/__init__.py +48 -0
  5. sentinel_probes-0.1.0/sentinel_probes/autostart.py +67 -0
  6. sentinel_probes-0.1.0/sentinel_probes/bootstrap/sitecustomize.py +29 -0
  7. sentinel_probes-0.1.0/sentinel_probes/capture.py +294 -0
  8. sentinel_probes-0.1.0/sentinel_probes/evaluator.py +649 -0
  9. sentinel_probes-0.1.0/sentinel_probes/http.py +190 -0
  10. sentinel_probes-0.1.0/sentinel_probes/instrumenter.py +713 -0
  11. sentinel_probes-0.1.0/sentinel_probes/pattern.py +597 -0
  12. sentinel_probes-0.1.0/sentinel_probes/run.py +31 -0
  13. sentinel_probes-0.1.0/sentinel_probes/runtime.py +555 -0
  14. sentinel_probes-0.1.0/sentinel_probes/spans.py +66 -0
  15. sentinel_probes-0.1.0/sentinel_probes.egg-info/PKG-INFO +127 -0
  16. sentinel_probes-0.1.0/sentinel_probes.egg-info/SOURCES.txt +24 -0
  17. sentinel_probes-0.1.0/sentinel_probes.egg-info/dependency_links.txt +1 -0
  18. sentinel_probes-0.1.0/sentinel_probes.egg-info/entry_points.txt +2 -0
  19. sentinel_probes-0.1.0/sentinel_probes.egg-info/top_level.txt +1 -0
  20. sentinel_probes-0.1.0/setup.cfg +4 -0
  21. sentinel_probes-0.1.0/tests/test_capture.py +262 -0
  22. sentinel_probes-0.1.0/tests/test_conformance_cases.py +82 -0
  23. sentinel_probes-0.1.0/tests/test_conformance_patterns.py +81 -0
  24. sentinel_probes-0.1.0/tests/test_hardening.py +299 -0
  25. sentinel_probes-0.1.0/tests/test_instrumenter.py +364 -0
  26. sentinel_probes-0.1.0/tests/test_runtime.py +292 -0
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: sentinel-probes
3
+ Version: 0.1.0
4
+ Summary: Sentinel Dynamic Instrumentation for Python: metric, span and span-tag probes placed in running code without a redeploy.
5
+ Author-email: A K M Muhibullah Nayem <nayem.drmc@gmail.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/MuhibNayem/sightglass/tree/main/clients/python
8
+ Project-URL: Source, https://github.com/MuhibNayem/sightglass
9
+ Project-URL: Issues, https://github.com/MuhibNayem/sightglass/issues
10
+ Keywords: sentinel,dynamic-instrumentation,observability,opentelemetry,apm,probes
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 :: System :: Monitoring
17
+ Classifier: Topic :: Software Development :: Debuggers
18
+ Requires-Python: >=3.8
19
+ Description-Content-Type: text/markdown
20
+
21
+ # sentinel-probes: Dynamic Instrumentation for Python
22
+
23
+ Metric, span and span-tag probes on a running Python service, added, changed
24
+ and removed from [Sentinel](https://github.com/MuhibNayem/sightglass) without a deploy. Standard library only.
25
+
26
+ ## Start it
27
+
28
+ With no code change, the way `ddtrace-run` and `opentelemetry-instrument` work.
29
+ Every Python process the command starts runs the client, including
30
+ pre-forked workers:
31
+
32
+ ```sh
33
+ pip install sentinel-probes
34
+
35
+ SENTINEL_PROBES_URL=https://ingest.example.com/otlp/v1/probes \
36
+ SENTINEL_API_KEY=<ingest key> \
37
+ OTEL_SERVICE_NAME=checkout \
38
+ OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production,service.version=1.4.2 \
39
+ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
40
+ sentinel-probes-run gunicorn app:app
41
+ ```
42
+
43
+ `SENTINEL_SERVICE`, `SENTINEL_ENV`, `SENTINEL_VERSION` and
44
+ `SENTINEL_OTLP_ENDPOINT` take precedence over the OpenTelemetry variables.
45
+ Without `SENTINEL_PROBES_URL` the client stays off. With the URL set but the
46
+ rest of the configuration incomplete, it logs one warning and stays off.
47
+
48
+ Or start it from code:
49
+
50
+ ```python
51
+ import sentinel_probes
52
+
53
+ sentinel_probes.start_probes(
54
+ service="checkout",
55
+ env="production",
56
+ api_key="<ingest key>",
57
+ version="1.4.2",
58
+ probes_url="https://ingest.example.com/otlp/v1/probes",
59
+ otlp_endpoint="http://otel-collector:4318",
60
+ )
61
+ ```
62
+
63
+ ## How it stays cheap
64
+
65
+ - **Python 3.12+:** probes are placed with `sys.monitoring` local events, only
66
+ on the targeted code objects. Every other function runs untouched.
67
+ - **Rate limit:** each probe runs at most 50 times a second. Once a probe has
68
+ used up that allowance, its code object's events are switched off for the rest
69
+ of the second, so a hot method runs at full speed between its sampled calls.
70
+ - **Before 3.12:** a method probe wraps the function. The signature is computed
71
+ once, the rate limit is checked before arguments are bound, and once the
72
+ allowance is used up the wrapper skips the lock entirely.
73
+ - **CPU budget:** probes may use at most 2% of a second's CPU time. Beyond
74
+ that, they're refused until the next second.
75
+ - **Off the application's threads:** a daemon thread polls, reports and exports.
76
+ It restarts in forked children with a new runtime id.
77
+ - **Raw-socket HTTP:** the client talks over a plain socket, not urllib,
78
+ http.client or requests, so OpenTelemetry's and ddtrace's instrumentation
79
+ never see its traffic. Each call is time-boxed and responses are capped at
80
+ 1 MiB.
81
+ - **Capture runs no application code:**
82
+ - attributes are read through descriptors, never via properties,
83
+ `__getattr__` or `__str__`;
84
+ - an exception's message is read only when its class keeps
85
+ `BaseException.__str__`;
86
+ - only exact standard-library types (`datetime`, `UUID`, `Decimal`, …) are
87
+ turned into text.
88
+ - **Bounded and redacted:** capture is bounded (depth 2, 20 entries, 256
89
+ characters, 30 variables) and redacted by name (`password`, `token`,
90
+ `session`, …, plus the probe's keywords).
91
+ - **Never into the application:** a failure becomes the probe's error.
92
+
93
+ Per call of a probed method (`tests/bench.py`). The probe is a gauge of the
94
+ return value; most calls are past the 50-a-second limit:
95
+
96
+ | Python | plain call | with the probe | added |
97
+ | ----------------------- | ---------: | -------------: | -------: |
98
+ | 3.13 (`sys.monitoring`) | 0.10 µs | 0.10 µs | +0.01 µs |
99
+ | 3.11 (function wrapper) | 0.09 µs | 0.51 µs | +0.43 µs |
100
+
101
+ ## What runs where
102
+
103
+ | Python | Method probes | Line probes |
104
+ | -------- | ------------- | ----------------------------------------------------------------------------------------------------------------------- |
105
+ | 3.12+ | yes | yes |
106
+ | 3.8–3.11 | yes | no: the only way is `sys.settrace`, which taxes every call in every thread. A line probe reports an error that says so. |
107
+
108
+ The client advertises what it can place (`locations=line,method` or `method`).
109
+
110
+ Before 3.12, the wrapper replaces the function where it is defined. Code that
111
+ imported it by name before the probe was placed (`from orders import total`)
112
+ keeps calling the original, and a nested function can't be reached at all; the
113
+ probe reports which.
114
+
115
+ ## Tests
116
+
117
+ ```sh
118
+ python -m pytest -q # unit, conformance (79 cases, 3,030 patterns), hardening
119
+ docker run --rm -v "$PWD":/w/python:ro -w /w/python/tests/e2e python:3.11-slim sh run.sh
120
+ docker run --rm -v "$PWD":/w/python:ro -w /w/python/tests/e2e python:3.13-slim sh run.sh
121
+ docker run --rm -v "$PWD":/w/python:ro -w /w/python python:3.13-slim python tests/bench.py
122
+ ```
123
+
124
+ The end-to-end run starts an unmodified application with
125
+ `python -m sentinel_probes.run` against a fake backend. It checks the poll,
126
+ statuses (including the line-probe refusal on 3.11), a redacted sample and the
127
+ OTLP metrics.
@@ -0,0 +1,107 @@
1
+ # sentinel-probes: Dynamic Instrumentation for Python
2
+
3
+ Metric, span and span-tag probes on a running Python service, added, changed
4
+ and removed from [Sentinel](https://github.com/MuhibNayem/sightglass) without a deploy. Standard library only.
5
+
6
+ ## Start it
7
+
8
+ With no code change, the way `ddtrace-run` and `opentelemetry-instrument` work.
9
+ Every Python process the command starts runs the client, including
10
+ pre-forked workers:
11
+
12
+ ```sh
13
+ pip install sentinel-probes
14
+
15
+ SENTINEL_PROBES_URL=https://ingest.example.com/otlp/v1/probes \
16
+ SENTINEL_API_KEY=<ingest key> \
17
+ OTEL_SERVICE_NAME=checkout \
18
+ OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production,service.version=1.4.2 \
19
+ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
20
+ sentinel-probes-run gunicorn app:app
21
+ ```
22
+
23
+ `SENTINEL_SERVICE`, `SENTINEL_ENV`, `SENTINEL_VERSION` and
24
+ `SENTINEL_OTLP_ENDPOINT` take precedence over the OpenTelemetry variables.
25
+ Without `SENTINEL_PROBES_URL` the client stays off. With the URL set but the
26
+ rest of the configuration incomplete, it logs one warning and stays off.
27
+
28
+ Or start it from code:
29
+
30
+ ```python
31
+ import sentinel_probes
32
+
33
+ sentinel_probes.start_probes(
34
+ service="checkout",
35
+ env="production",
36
+ api_key="<ingest key>",
37
+ version="1.4.2",
38
+ probes_url="https://ingest.example.com/otlp/v1/probes",
39
+ otlp_endpoint="http://otel-collector:4318",
40
+ )
41
+ ```
42
+
43
+ ## How it stays cheap
44
+
45
+ - **Python 3.12+:** probes are placed with `sys.monitoring` local events, only
46
+ on the targeted code objects. Every other function runs untouched.
47
+ - **Rate limit:** each probe runs at most 50 times a second. Once a probe has
48
+ used up that allowance, its code object's events are switched off for the rest
49
+ of the second, so a hot method runs at full speed between its sampled calls.
50
+ - **Before 3.12:** a method probe wraps the function. The signature is computed
51
+ once, the rate limit is checked before arguments are bound, and once the
52
+ allowance is used up the wrapper skips the lock entirely.
53
+ - **CPU budget:** probes may use at most 2% of a second's CPU time. Beyond
54
+ that, they're refused until the next second.
55
+ - **Off the application's threads:** a daemon thread polls, reports and exports.
56
+ It restarts in forked children with a new runtime id.
57
+ - **Raw-socket HTTP:** the client talks over a plain socket, not urllib,
58
+ http.client or requests, so OpenTelemetry's and ddtrace's instrumentation
59
+ never see its traffic. Each call is time-boxed and responses are capped at
60
+ 1 MiB.
61
+ - **Capture runs no application code:**
62
+ - attributes are read through descriptors, never via properties,
63
+ `__getattr__` or `__str__`;
64
+ - an exception's message is read only when its class keeps
65
+ `BaseException.__str__`;
66
+ - only exact standard-library types (`datetime`, `UUID`, `Decimal`, …) are
67
+ turned into text.
68
+ - **Bounded and redacted:** capture is bounded (depth 2, 20 entries, 256
69
+ characters, 30 variables) and redacted by name (`password`, `token`,
70
+ `session`, …, plus the probe's keywords).
71
+ - **Never into the application:** a failure becomes the probe's error.
72
+
73
+ Per call of a probed method (`tests/bench.py`). The probe is a gauge of the
74
+ return value; most calls are past the 50-a-second limit:
75
+
76
+ | Python | plain call | with the probe | added |
77
+ | ----------------------- | ---------: | -------------: | -------: |
78
+ | 3.13 (`sys.monitoring`) | 0.10 µs | 0.10 µs | +0.01 µs |
79
+ | 3.11 (function wrapper) | 0.09 µs | 0.51 µs | +0.43 µs |
80
+
81
+ ## What runs where
82
+
83
+ | Python | Method probes | Line probes |
84
+ | -------- | ------------- | ----------------------------------------------------------------------------------------------------------------------- |
85
+ | 3.12+ | yes | yes |
86
+ | 3.8–3.11 | yes | no: the only way is `sys.settrace`, which taxes every call in every thread. A line probe reports an error that says so. |
87
+
88
+ The client advertises what it can place (`locations=line,method` or `method`).
89
+
90
+ Before 3.12, the wrapper replaces the function where it is defined. Code that
91
+ imported it by name before the probe was placed (`from orders import total`)
92
+ keeps calling the original, and a nested function can't be reached at all; the
93
+ probe reports which.
94
+
95
+ ## Tests
96
+
97
+ ```sh
98
+ python -m pytest -q # unit, conformance (79 cases, 3,030 patterns), hardening
99
+ docker run --rm -v "$PWD":/w/python:ro -w /w/python/tests/e2e python:3.11-slim sh run.sh
100
+ docker run --rm -v "$PWD":/w/python:ro -w /w/python/tests/e2e python:3.13-slim sh run.sh
101
+ docker run --rm -v "$PWD":/w/python:ro -w /w/python python:3.13-slim python tests/bench.py
102
+ ```
103
+
104
+ The end-to-end run starts an unmodified application with
105
+ `python -m sentinel_probes.run` against a fake backend. It checks the poll,
106
+ statuses (including the line-probe refusal on 3.11), a redacted sample and the
107
+ OTLP metrics.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sentinel-probes"
7
+ version = "0.1.0"
8
+ description = "Sentinel Dynamic Instrumentation for Python: metric, span and span-tag probes placed in running code without a redeploy."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ dependencies = []
12
+ license = "Apache-2.0"
13
+ authors = [{ name = "A K M Muhibullah Nayem", email = "nayem.drmc@gmail.com" }]
14
+ keywords = ["sentinel", "dynamic-instrumentation", "observability", "opentelemetry", "apm", "probes"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Topic :: System :: Monitoring",
22
+ "Topic :: Software Development :: Debuggers",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/MuhibNayem/sightglass/tree/main/clients/python"
27
+ Source = "https://github.com/MuhibNayem/sightglass"
28
+ Issues = "https://github.com/MuhibNayem/sightglass/issues"
29
+
30
+ [project.scripts]
31
+ sentinel-probes-run = "sentinel_probes.run:main"
32
+
33
+ [tool.setuptools]
34
+ packages = ["sentinel_probes", "sentinel_probes.bootstrap"]
35
+
36
+ [tool.pytest.ini_options]
37
+ pythonpath = ["."]
38
+ testpaths = ["tests"]
@@ -0,0 +1,48 @@
1
+ """
2
+ Sentinel Dynamic Instrumentation Client for Python.
3
+
4
+ Zero external dependencies, ReDoS-safe linear regex engine, safe variable capture
5
+ without descriptor/property execution, CPU governor, and PEP 669 sys.monitoring.
6
+ """
7
+
8
+ from .runtime import start_probes, CLIENT_VERSION, Runtime
9
+ from .evaluator import (
10
+ evaluate,
11
+ validate,
12
+ render_tag,
13
+ reads,
14
+ text,
15
+ ProbeError,
16
+ Capture,
17
+ AST_VERSION,
18
+ )
19
+ from .pattern import compile_pattern, compiled, run_pattern, code_points
20
+ from .capture import capture_frame, capture_locals, value_of, scrubbed
21
+ from .instrumenter import Instrumenter
22
+ from .spans import active_span
23
+ from . import http
24
+
25
+ __all__ = [
26
+ "start_probes",
27
+ "CLIENT_VERSION",
28
+ "Runtime",
29
+ "evaluate",
30
+ "validate",
31
+ "render_tag",
32
+ "reads",
33
+ "text",
34
+ "ProbeError",
35
+ "Capture",
36
+ "AST_VERSION",
37
+ "compile_pattern",
38
+ "compiled",
39
+ "run_pattern",
40
+ "code_points",
41
+ "capture_frame",
42
+ "capture_locals",
43
+ "value_of",
44
+ "scrubbed",
45
+ "Instrumenter",
46
+ "active_span",
47
+ "http",
48
+ ]
@@ -0,0 +1,67 @@
1
+ """
2
+ Start the client from the environment, with no code change: what
3
+ `sentinel-probes-run` (and the sitecustomize it installs) calls.
4
+
5
+ SENTINEL_PROBES_URL https://sightglass.example.com/otlp/v1/probes
6
+ SENTINEL_API_KEY the ingest key
7
+ SENTINEL_SERVICE or OTEL_SERVICE_NAME
8
+ SENTINEL_ENV or deployment.environment(.name) in OTEL_RESOURCE_ATTRIBUTES
9
+ SENTINEL_VERSION or service.version in OTEL_RESOURCE_ATTRIBUTES (optional)
10
+ SENTINEL_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT (default http://localhost:4318)
11
+
12
+ Missing configuration means the client stays off, with one warning; it never
13
+ raises into the application's start-up.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ from typing import Any
21
+
22
+ _runtime: Any = None
23
+ _log = logging.getLogger("sentinel_probes")
24
+
25
+
26
+ def _resource_attribute(*names: str) -> str:
27
+ raw = os.environ.get("OTEL_RESOURCE_ATTRIBUTES", "")
28
+ for pair in raw.split(","):
29
+ key, sep, value = pair.partition("=")
30
+ if sep and key.strip() in names:
31
+ return value.strip()
32
+ return ""
33
+
34
+
35
+ def start_from_env() -> Any:
36
+ """Start once per process; None when not configured. Forked children restart
37
+ their own poller (Runtime registers an at-fork handler)."""
38
+ global _runtime
39
+ if _runtime is not None:
40
+ return _runtime
41
+ env = os.environ
42
+ url = env.get("SENTINEL_PROBES_URL", "")
43
+ if not url:
44
+ return None
45
+ service = env.get("SENTINEL_SERVICE") or env.get("OTEL_SERVICE_NAME", "")
46
+ environment = env.get("SENTINEL_ENV") or _resource_attribute("deployment.environment.name", "deployment.environment")
47
+ api_key = env.get("SENTINEL_API_KEY", "")
48
+ if not (service and environment and api_key):
49
+ _log.warning(
50
+ "[sentinel-probes] not started: set SENTINEL_SERVICE (or OTEL_SERVICE_NAME), SENTINEL_ENV and SENTINEL_API_KEY"
51
+ )
52
+ return None
53
+ try:
54
+ from .runtime import start_probes
55
+
56
+ _runtime = start_probes(
57
+ service=service,
58
+ env=environment,
59
+ api_key=api_key,
60
+ version=env.get("SENTINEL_VERSION") or _resource_attribute("service.version"),
61
+ probes_url=url,
62
+ otlp_endpoint=env.get("SENTINEL_OTLP_ENDPOINT") or env.get("OTEL_EXPORTER_OTLP_ENDPOINT") or "http://localhost:4318",
63
+ )
64
+ except Exception as e: # never into the application
65
+ _log.warning("[sentinel-probes] not started: %s", e)
66
+ return None
67
+ return _runtime
@@ -0,0 +1,29 @@
1
+ """
2
+ Put on PYTHONPATH by `sentinel-probes-run`: starts the client as the interpreter
3
+ starts, then hands over to any sitecustomize the environment already had.
4
+ """
5
+
6
+ import importlib.util
7
+ import os
8
+ import sys
9
+
10
+ try:
11
+ from sentinel_probes.autostart import start_from_env
12
+
13
+ start_from_env()
14
+ except Exception: # never into the application
15
+ pass
16
+
17
+ # Chain to the next sitecustomize on the path, as if this one were not here.
18
+ _here = os.path.dirname(os.path.abspath(__file__))
19
+ for _entry in sys.path:
20
+ if _entry and os.path.abspath(_entry) != _here:
21
+ _candidate = os.path.join(_entry, "sitecustomize.py")
22
+ if os.path.isfile(_candidate):
23
+ _loader_spec = importlib.util.spec_from_file_location("_sentinel_chained_sitecustomize", _candidate)
24
+ if _loader_spec and _loader_spec.loader:
25
+ try:
26
+ _loader_spec.loader.exec_module(importlib.util.module_from_spec(_loader_spec))
27
+ except Exception:
28
+ pass
29
+ break