deployangel 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deployangel/__init__.py +176 -0
- deployangel/agent.py +270 -0
- deployangel/asgi.py +216 -0
- deployangel/celery.py +278 -0
- deployangel/config.py +71 -0
- deployangel/core/__init__.py +0 -0
- deployangel/core/aggregator.py +193 -0
- deployangel/core/buffer.py +36 -0
- deployangel/core/fingerprint.py +162 -0
- deployangel/core/histogram.py +34 -0
- deployangel/core/instance.py +34 -0
- deployangel/core/protocol.py +69 -0
- deployangel/core/redaction.py +23 -0
- deployangel/core/release.py +155 -0
- deployangel/core/transport.py +87 -0
- deployangel/django/__init__.py +3 -0
- deployangel/django/apps.py +51 -0
- deployangel/django/metadata.py +75 -0
- deployangel/django/middleware.py +145 -0
- deployangel/fastapi.py +9 -0
- deployangel/flask.py +130 -0
- deployangel/http.py +69 -0
- deployangel/metadata.py +154 -0
- deployangel/rq.py +103 -0
- deployangel/starlette.py +9 -0
- deployangel/version.py +1 -0
- deployangel-0.1.0.dist-info/METADATA +343 -0
- deployangel-0.1.0.dist-info/RECORD +30 -0
- deployangel-0.1.0.dist-info/WHEEL +4 -0
- deployangel-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
deployangel/__init__.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""DeployAngel agent for Python web apps and job workers.
|
|
2
|
+
|
|
3
|
+
Framework integrations call start(); the rest of this module is the API they
|
|
4
|
+
record through, plus checkpoint() and notify() for application code. Every
|
|
5
|
+
function here is safe to call anywhere: it never raises and never touches the
|
|
6
|
+
network."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import atexit
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from deployangel.agent import Agent
|
|
18
|
+
from deployangel.config import Configuration
|
|
19
|
+
from deployangel.version import VERSION
|
|
20
|
+
|
|
21
|
+
__all__ = ["VERSION", "checkpoint", "configuration", "configure", "notify", "recording", "shutdown", "start"]
|
|
22
|
+
|
|
23
|
+
# Web servers and job workers report from boot, so idle processes still send
|
|
24
|
+
# heartbeats; other processes (manage.py commands, shells) only report after
|
|
25
|
+
# recording something.
|
|
26
|
+
EAGER_PROGRAMS = re.compile(r"\b(gunicorn|uvicorn|hypercorn|daphne|granian|waitress|uwsgi|celery|rqworker|rq)\b")
|
|
27
|
+
CHECKPOINT_NAME = re.compile(r"\A[a-z0-9][a-z0-9_.:-]{0,99}\Z", re.IGNORECASE)
|
|
28
|
+
|
|
29
|
+
_configuration: Optional[Configuration] = None
|
|
30
|
+
_agent: Optional[Agent] = None
|
|
31
|
+
_lock = threading.Lock()
|
|
32
|
+
_capabilities = ["http", "exceptions"]
|
|
33
|
+
_metadata_sources: list = []
|
|
34
|
+
_hooks_installed = False
|
|
35
|
+
_warned_checkpoint = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def configuration() -> Configuration:
|
|
39
|
+
global _configuration
|
|
40
|
+
if _configuration is None:
|
|
41
|
+
_configuration = Configuration()
|
|
42
|
+
return _configuration
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def configure(**options) -> Configuration:
|
|
46
|
+
"""deployangel.configure(environments=["production", "staging"])"""
|
|
47
|
+
config = configuration()
|
|
48
|
+
config.update(**options)
|
|
49
|
+
return config
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def agent() -> Optional[Agent]:
|
|
53
|
+
return _agent
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def start(environment: Optional[str] = None, root: Optional[str] = None, framework: Optional[str] = None,
|
|
57
|
+
framework_version: Optional[str] = None, eager: Optional[bool] = None, debug: bool = False) -> Optional[Agent]:
|
|
58
|
+
"""Starts this process's agent, once; later calls return the same agent.
|
|
59
|
+
Framework integrations call it at boot. debug is the framework's debug
|
|
60
|
+
flag, which names the environment "development" unless it's configured."""
|
|
61
|
+
global _agent
|
|
62
|
+
try:
|
|
63
|
+
with _lock:
|
|
64
|
+
if _agent is not None:
|
|
65
|
+
return _agent
|
|
66
|
+
config = configuration()
|
|
67
|
+
environment = config.environment or environment or ("development" if debug else "production")
|
|
68
|
+
_agent = Agent(config=config, environment=environment, root=config.root or root or os.getcwd(),
|
|
69
|
+
framework=framework, framework_version=framework_version,
|
|
70
|
+
eager=server_process() if eager is None else eager, capabilities=_capabilities)
|
|
71
|
+
from deployangel.metadata import Metadata
|
|
72
|
+
|
|
73
|
+
_agent.metadata = Metadata(config=config, root=_agent.root, environment=environment, sources=_metadata_sources)
|
|
74
|
+
_install_process_hooks()
|
|
75
|
+
return _agent
|
|
76
|
+
except Exception as error:
|
|
77
|
+
try:
|
|
78
|
+
configuration().logger.warning("DeployAngel failed to start: %s: %s", type(error).__name__, error)
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def recording() -> bool:
|
|
85
|
+
return _agent is not None and _agent.active
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def checkpoint(name: str, count: int = 1) -> None:
|
|
89
|
+
"""Counts a business event, such as deployangel.checkpoint("order.fulfilled").
|
|
90
|
+
DeployAngel learns each checkpoint's normal rate and fails a release after
|
|
91
|
+
which it drops sharply or stops."""
|
|
92
|
+
global _warned_checkpoint
|
|
93
|
+
try:
|
|
94
|
+
name = str(name)
|
|
95
|
+
if not CHECKPOINT_NAME.match(name) or not isinstance(count, int) or isinstance(count, bool) or count <= 0:
|
|
96
|
+
if not _warned_checkpoint:
|
|
97
|
+
configuration().logger.warning("DeployAngel ignored checkpoint %r: use letters, numbers, and . _ : - (max 100)", name)
|
|
98
|
+
_warned_checkpoint = True
|
|
99
|
+
return
|
|
100
|
+
add_capability("checkpoints")
|
|
101
|
+
if _agent is not None:
|
|
102
|
+
_agent.record_checkpoint(name, count)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def notify(exception: BaseException) -> None:
|
|
108
|
+
"""Reports an exception the app caught and handled itself. It's shown for
|
|
109
|
+
context; only unhandled exceptions count toward verdicts."""
|
|
110
|
+
if _agent is not None:
|
|
111
|
+
_agent.record_exception(exception, handled=True)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def record_request(route_key: str, status: int, duration_ms: float, unhandled: bool = False, in_totals: bool = True) -> None:
|
|
115
|
+
if _agent is not None:
|
|
116
|
+
_agent.record_request(route_key, status, duration_ms, unhandled=unhandled, in_totals=in_totals)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def record_job(job_class: str, duration_ms: float, failed: bool = False, discarded: bool = False,
|
|
120
|
+
queue_latency_ms: Optional[float] = None) -> None:
|
|
121
|
+
if _agent is not None:
|
|
122
|
+
_agent.record_job(job_class, duration_ms, failed=failed, discarded=discarded, queue_latency_ms=queue_latency_ms)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def record_exception(exception: BaseException, source: Optional[str] = None, handled: bool = False) -> None:
|
|
126
|
+
if _agent is not None:
|
|
127
|
+
_agent.record_exception(exception, source=source, handled=handled)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def add_capability(name: str) -> None:
|
|
131
|
+
"""Signals this process can observe, announced in every payload so the
|
|
132
|
+
cloud never claims to verify what the agent cannot see."""
|
|
133
|
+
if name not in _capabilities:
|
|
134
|
+
_capabilities.append(name)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def add_metadata_source(source) -> None:
|
|
138
|
+
"""An integration that knows part of the app: an object with any of
|
|
139
|
+
routes(), job_classes(), job_class_files(), and schedules()."""
|
|
140
|
+
if source not in _metadata_sources:
|
|
141
|
+
_metadata_sources.append(source)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def after_fork() -> None:
|
|
145
|
+
if _agent is not None:
|
|
146
|
+
_agent.after_fork()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def shutdown() -> None:
|
|
150
|
+
if _agent is not None:
|
|
151
|
+
_agent.shutdown()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def server_process() -> bool:
|
|
155
|
+
argv = getattr(sys, "orig_argv", None) or sys.argv
|
|
156
|
+
return bool(EAGER_PROGRAMS.search(" ".join(os.path.basename(str(arg)) if i == 0 else str(arg)
|
|
157
|
+
for i, arg in enumerate(argv[:4]))))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _install_process_hooks() -> None:
|
|
161
|
+
global _hooks_installed
|
|
162
|
+
if _hooks_installed:
|
|
163
|
+
return
|
|
164
|
+
_hooks_installed = True
|
|
165
|
+
atexit.register(shutdown)
|
|
166
|
+
if hasattr(os, "register_at_fork"):
|
|
167
|
+
os.register_at_fork(after_in_child=after_fork)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _reset_for_tests() -> None:
|
|
171
|
+
global _agent, _configuration, _warned_checkpoint
|
|
172
|
+
_agent = None
|
|
173
|
+
_configuration = None
|
|
174
|
+
_warned_checkpoint = False
|
|
175
|
+
del _capabilities[2:]
|
|
176
|
+
_metadata_sources.clear()
|
deployangel/agent.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""The per-process runtime: records requests and jobs into the aggregator and
|
|
2
|
+
sends completed minutes from a background thread. Every public method fails
|
|
3
|
+
open; nothing here may raise into, or block, the customer's app."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from typing import Callable, Mapping, Optional
|
|
12
|
+
|
|
13
|
+
from deployangel.core import fingerprint, protocol, redaction
|
|
14
|
+
from deployangel.core import release as release_module
|
|
15
|
+
from deployangel.core.aggregator import Aggregator
|
|
16
|
+
from deployangel.core.buffer import Buffer
|
|
17
|
+
from deployangel.core.instance import Instance
|
|
18
|
+
from deployangel.core.transport import Transport, encode
|
|
19
|
+
|
|
20
|
+
TELEMETRY_PATH = "/api/v1/telemetry"
|
|
21
|
+
METADATA_PATH = "/api/v1/application_metadata"
|
|
22
|
+
SHUTDOWN_TIMEOUT = 2.0
|
|
23
|
+
DEFAULT_PAUSE = 60
|
|
24
|
+
# Seconds past each minute a process waits before sending, chosen once per
|
|
25
|
+
# process so the cloud gets a steady stream instead of a burst at the top of
|
|
26
|
+
# the minute. The cloud reads a minute 2 minutes after it ends, so a send that
|
|
27
|
+
# times out and goes again a minute later still lands in time.
|
|
28
|
+
FLUSH_JITTER = (1.0, 50.0)
|
|
29
|
+
# Set on an exception object once it's recorded, whichever instrumentation
|
|
30
|
+
# sees it first.
|
|
31
|
+
SEEN = "__deployangel_recorded__"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Agent:
|
|
35
|
+
def __init__(self, config, environment: str, root: Optional[str] = None, framework: Optional[str] = None,
|
|
36
|
+
framework_version: Optional[str] = None, env: Mapping[str, str] = os.environ, transport=None,
|
|
37
|
+
clock: Callable[[], float] = time.time, eager: bool = False, capabilities: Optional[list] = None):
|
|
38
|
+
self.config = config
|
|
39
|
+
self.active = config.is_active(environment)
|
|
40
|
+
self.environment = environment
|
|
41
|
+
self.root = root
|
|
42
|
+
self.release = release_module.resolve(config, env=env, root=root) if self.active else release_module.Release(None, None, "unknown")
|
|
43
|
+
self.runtime = protocol.runtime(framework, framework_version)
|
|
44
|
+
self.metadata = None
|
|
45
|
+
self.capabilities = capabilities if capabilities is not None else ["http", "exceptions"]
|
|
46
|
+
self._transport = transport or Transport(config)
|
|
47
|
+
self._env = env
|
|
48
|
+
self._clock = clock
|
|
49
|
+
self._eager = eager
|
|
50
|
+
self._warned: set = set()
|
|
51
|
+
self._reset_process_state()
|
|
52
|
+
if self.active and self.release.unknown:
|
|
53
|
+
self._warn_once("unknown_release", "DeployAngel could not determine the release; set DEPLOYANGEL_REVISION "
|
|
54
|
+
"or enable Heroku dyno metadata. Telemetry will not be attributed to deployments.")
|
|
55
|
+
if self.active and eager:
|
|
56
|
+
self.start_reporter()
|
|
57
|
+
|
|
58
|
+
def record_request(self, route_key: str, status: int, duration_ms: float, unhandled: bool = False, in_totals: bool = True) -> None:
|
|
59
|
+
if not self.active:
|
|
60
|
+
return
|
|
61
|
+
try:
|
|
62
|
+
self._check_fork()
|
|
63
|
+
self.start_reporter()
|
|
64
|
+
self._aggregator.record(route_key, status, duration_ms, unhandled=unhandled, in_totals=in_totals)
|
|
65
|
+
except Exception as error:
|
|
66
|
+
self._warn_once("record", f"DeployAngel failed to record a request: {type(error).__name__}: {error}")
|
|
67
|
+
|
|
68
|
+
def record_job(self, job_class: str, duration_ms: float, failed: bool = False, discarded: bool = False,
|
|
69
|
+
queue_latency_ms: Optional[float] = None) -> None:
|
|
70
|
+
if not self.active:
|
|
71
|
+
return
|
|
72
|
+
try:
|
|
73
|
+
self._check_fork()
|
|
74
|
+
self.start_reporter()
|
|
75
|
+
self._aggregator.record_job(job_class, duration_ms, failed=failed, discarded=discarded, queue_latency_ms=queue_latency_ms)
|
|
76
|
+
except Exception as error:
|
|
77
|
+
self._warn_once("record_job", f"DeployAngel failed to record a job: {type(error).__name__}: {error}")
|
|
78
|
+
|
|
79
|
+
def record_discard(self, job_class: str) -> None:
|
|
80
|
+
if not self.active:
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
self._check_fork()
|
|
84
|
+
self._aggregator.record_discard(job_class)
|
|
85
|
+
except Exception as error:
|
|
86
|
+
self._warn_once("record_discard", f"DeployAngel failed to record a discarded job: {type(error).__name__}: {error}")
|
|
87
|
+
|
|
88
|
+
def record_checkpoint(self, name: str, count: int = 1) -> None:
|
|
89
|
+
if not self.active:
|
|
90
|
+
return
|
|
91
|
+
try:
|
|
92
|
+
self._check_fork()
|
|
93
|
+
self.start_reporter()
|
|
94
|
+
self._aggregator.record_checkpoint(name, count)
|
|
95
|
+
except Exception as error:
|
|
96
|
+
self._warn_once("record_checkpoint", f"DeployAngel failed to record a checkpoint: {type(error).__name__}: {error}")
|
|
97
|
+
|
|
98
|
+
def record_exception(self, exception: BaseException, source: Optional[str] = None, handled: bool = False) -> None:
|
|
99
|
+
"""Records each exception object once, whichever instrumentation sees it
|
|
100
|
+
first."""
|
|
101
|
+
if not self.active or not isinstance(exception, BaseException):
|
|
102
|
+
return
|
|
103
|
+
try:
|
|
104
|
+
if getattr(exception, SEEN, False):
|
|
105
|
+
return
|
|
106
|
+
try:
|
|
107
|
+
setattr(exception, SEEN, True)
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
messages = self.config.exception_messages
|
|
111
|
+
details = fingerprint.for_exception(exception, self.root, message=messages,
|
|
112
|
+
redactions=redaction.current() if messages else ())
|
|
113
|
+
self.record_exception_details(details, fingerprint.backtrace(exception, self.root), source=source, handled=handled)
|
|
114
|
+
except Exception as error:
|
|
115
|
+
self._warn_once("record_exception", f"DeployAngel failed to record an exception: {type(error).__name__}: {error}")
|
|
116
|
+
|
|
117
|
+
def record_exception_details(self, details: dict, backtrace: Optional[list], source: Optional[str] = None,
|
|
118
|
+
handled: bool = False) -> None:
|
|
119
|
+
"""An exception already fingerprinted, such as one parsed from a
|
|
120
|
+
formatted traceback."""
|
|
121
|
+
if not self.active:
|
|
122
|
+
return
|
|
123
|
+
try:
|
|
124
|
+
self._check_fork()
|
|
125
|
+
self.start_reporter()
|
|
126
|
+
self._aggregator.record_exception(details, source=source, handled=handled, backtrace=backtrace)
|
|
127
|
+
except Exception as error:
|
|
128
|
+
self._warn_once("record_exception", f"DeployAngel failed to record an exception: {type(error).__name__}: {error}")
|
|
129
|
+
|
|
130
|
+
def flush(self, include_current: bool = False, deadline: Optional[float] = None) -> int:
|
|
131
|
+
"""Drains completed minutes into the buffer, encoded so unsent minutes
|
|
132
|
+
stay small while DeployAngel is unreachable, and sends what it can."""
|
|
133
|
+
if not self.active:
|
|
134
|
+
return 0
|
|
135
|
+
try:
|
|
136
|
+
for period in self._aggregator.drain(include_current=include_current, max_periods=self.config.max_queued_payloads):
|
|
137
|
+
self._buffer.push(encode(protocol.telemetry(period, self.instance, self.release, self.runtime, self.capabilities)))
|
|
138
|
+
return self._send_buffered(deadline)
|
|
139
|
+
except Exception as error:
|
|
140
|
+
self._warn_once("flush", f"DeployAngel failed to flush telemetry: {type(error).__name__}: {error}")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
def start_reporter(self) -> None:
|
|
144
|
+
if not self.active or (self._thread is not None and self._thread.is_alive()):
|
|
145
|
+
return
|
|
146
|
+
with self._thread_lock:
|
|
147
|
+
if self._thread is not None and self._thread.is_alive():
|
|
148
|
+
return
|
|
149
|
+
self._stopping.clear()
|
|
150
|
+
thread = threading.Thread(target=self._run_reporter, name="deployangel-reporter", daemon=True)
|
|
151
|
+
thread.start()
|
|
152
|
+
self._thread = thread
|
|
153
|
+
|
|
154
|
+
def shutdown(self, timeout: float = SHUTDOWN_TIMEOUT) -> None:
|
|
155
|
+
"""Sends the in-progress minute too, bounded by a short timeout, because
|
|
156
|
+
deployments restart processes. Runs on the calling thread: new threads
|
|
157
|
+
can't start while the interpreter shuts down."""
|
|
158
|
+
if not self.active:
|
|
159
|
+
return
|
|
160
|
+
try:
|
|
161
|
+
if os.getpid() != self._pid:
|
|
162
|
+
return
|
|
163
|
+
self._stopping.set()
|
|
164
|
+
self.flush(include_current=True, deadline=time.monotonic() + timeout)
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
def after_fork(self) -> None:
|
|
169
|
+
"""Threads do not survive fork, and the parent's identity and counts
|
|
170
|
+
must not be reused by the child."""
|
|
171
|
+
self._reset_process_state()
|
|
172
|
+
if self.active and self._eager:
|
|
173
|
+
self.start_reporter()
|
|
174
|
+
|
|
175
|
+
def send_metadata(self) -> None:
|
|
176
|
+
"""Sent once per process; retried on the next minute if it fails. File
|
|
177
|
+
digests are only uploaded when the cloud has not seen the manifest."""
|
|
178
|
+
if self._metadata_sent or self.metadata is None or self._paused():
|
|
179
|
+
return
|
|
180
|
+
try:
|
|
181
|
+
base = {"protocol_version": protocol.VERSION, "instance": self.instance.to_protocol(),
|
|
182
|
+
"release": self.release.to_protocol(), "runtime": self.runtime}
|
|
183
|
+
base.update(self.metadata.to_protocol())
|
|
184
|
+
result = self._transport.post(METADATA_PATH, base)
|
|
185
|
+
if not result.ok:
|
|
186
|
+
return
|
|
187
|
+
if isinstance(result.body, dict) and result.body.get("manifest_needed"):
|
|
188
|
+
if not self._transport.post(METADATA_PATH, dict(base, files=self.metadata.files())).ok:
|
|
189
|
+
return
|
|
190
|
+
self._metadata_sent = True
|
|
191
|
+
except Exception as error:
|
|
192
|
+
self._warn_once("metadata", f"DeployAngel failed to send application metadata: {type(error).__name__}: {error}")
|
|
193
|
+
|
|
194
|
+
def _check_fork(self) -> None:
|
|
195
|
+
# A fork the interpreter didn't see, such as uWSGI's, which forks
|
|
196
|
+
# workers from C.
|
|
197
|
+
if os.getpid() != self._pid:
|
|
198
|
+
self.after_fork()
|
|
199
|
+
|
|
200
|
+
def _reset_process_state(self) -> None:
|
|
201
|
+
self._pid = os.getpid()
|
|
202
|
+
self.instance = Instance(env=self._env, now=self._clock())
|
|
203
|
+
self._aggregator = Aggregator(max_routes=self.config.max_routes, clock=self._clock)
|
|
204
|
+
self._buffer = Buffer(self.config.max_queued_payloads)
|
|
205
|
+
self._paused_until: Optional[float] = None
|
|
206
|
+
self._metadata_sent = False
|
|
207
|
+
self._thread: Optional[threading.Thread] = None
|
|
208
|
+
self._thread_lock = threading.Lock()
|
|
209
|
+
self._stopping = threading.Event()
|
|
210
|
+
self._jitter = random.uniform(*FLUSH_JITTER)
|
|
211
|
+
|
|
212
|
+
def _run_reporter(self) -> None:
|
|
213
|
+
try:
|
|
214
|
+
while not self._stopping.is_set():
|
|
215
|
+
if self._stopping.wait(self._seconds_until_next_flush()):
|
|
216
|
+
break
|
|
217
|
+
self.send_metadata()
|
|
218
|
+
self.flush()
|
|
219
|
+
except Exception as error:
|
|
220
|
+
self._warn_once("reporter", f"DeployAngel reporter stopped: {type(error).__name__}: {error}")
|
|
221
|
+
|
|
222
|
+
def _seconds_until_next_flush(self) -> float:
|
|
223
|
+
interval = self.config.flush_interval
|
|
224
|
+
return (interval - (self._clock() % interval)) + self._jitter
|
|
225
|
+
|
|
226
|
+
def _send_buffered(self, deadline: Optional[float] = None) -> int:
|
|
227
|
+
sent = 0
|
|
228
|
+
while True:
|
|
229
|
+
payload = self._buffer.shift()
|
|
230
|
+
if payload is None:
|
|
231
|
+
break
|
|
232
|
+
remaining = None
|
|
233
|
+
if deadline is not None:
|
|
234
|
+
remaining = deadline - time.monotonic()
|
|
235
|
+
if remaining <= 0:
|
|
236
|
+
self._buffer.unshift(payload)
|
|
237
|
+
break
|
|
238
|
+
if self._paused():
|
|
239
|
+
self._buffer.unshift(payload)
|
|
240
|
+
break
|
|
241
|
+
result = self._transport.post(TELEMETRY_PATH, payload, timeout=min(remaining, self.config.timeout) if remaining else None)
|
|
242
|
+
if result.outcome == "ok":
|
|
243
|
+
sent += 1
|
|
244
|
+
elif result.outcome == "retry":
|
|
245
|
+
self._buffer.unshift(payload)
|
|
246
|
+
break
|
|
247
|
+
else:
|
|
248
|
+
self._handle_rejection(result)
|
|
249
|
+
return sent
|
|
250
|
+
|
|
251
|
+
def _handle_rejection(self, result) -> None:
|
|
252
|
+
if result.status == 429:
|
|
253
|
+
self._paused_until = self._clock() + (result.retry_after if result.retry_after and result.retry_after > 0 else DEFAULT_PAUSE)
|
|
254
|
+
elif result.status in (401, 403):
|
|
255
|
+
self._warn_once("auth", f"DeployAngel rejected the token (HTTP {result.status}); check DEPLOYANGEL_TOKEN "
|
|
256
|
+
"and that it has the telemetry scope.")
|
|
257
|
+
else:
|
|
258
|
+
self._warn_once(f"rejected_{result.status}", f"DeployAngel rejected a telemetry payload (HTTP {result.status}).")
|
|
259
|
+
|
|
260
|
+
def _paused(self) -> bool:
|
|
261
|
+
return self._paused_until is not None and self._clock() < self._paused_until
|
|
262
|
+
|
|
263
|
+
def _warn_once(self, key: str, message: str) -> None:
|
|
264
|
+
if key in self._warned:
|
|
265
|
+
return
|
|
266
|
+
self._warned.add(key)
|
|
267
|
+
try:
|
|
268
|
+
self.config.logger.warning(message)
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
deployangel/asgi.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""ASGI integration for Starlette and FastAPI: a middleware that records each
|
|
2
|
+
request against the route the router matched ("GET /items/{item_id}"), and
|
|
3
|
+
the route table for the metadata.
|
|
4
|
+
|
|
5
|
+
app = FastAPI()
|
|
6
|
+
deployangel.fastapi.init(app)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import deployangel
|
|
15
|
+
from deployangel import http
|
|
16
|
+
from deployangel.core import redaction
|
|
17
|
+
from deployangel.metadata import module_file
|
|
18
|
+
|
|
19
|
+
IGNORED_METHODS = ("HEAD", "OPTIONS")
|
|
20
|
+
# The documentation pages FastAPI serves itself.
|
|
21
|
+
FRAMEWORK_MODULES = ("fastapi.", "starlette.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def init(app):
|
|
25
|
+
"""Adds the middleware, lists the app's routes, and starts the agent. Call
|
|
26
|
+
it where the app is created, before it serves requests."""
|
|
27
|
+
try:
|
|
28
|
+
app.add_middleware(DeployAngelMiddleware)
|
|
29
|
+
deployangel.add_metadata_source(StarletteRoutes(app))
|
|
30
|
+
framework, version = _framework(app)
|
|
31
|
+
return deployangel.start(framework=framework, framework_version=version, debug=bool(getattr(app, "debug", False)))
|
|
32
|
+
except Exception as error:
|
|
33
|
+
deployangel.configuration().logger.warning("DeployAngel failed to start: %s: %s", type(error).__name__, error)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DeployAngelMiddleware:
|
|
38
|
+
def __init__(self, app):
|
|
39
|
+
self.app = app
|
|
40
|
+
|
|
41
|
+
async def __call__(self, scope, receive, send):
|
|
42
|
+
if scope.get("type") != "http" or not deployangel.recording():
|
|
43
|
+
await self.app(scope, receive, send)
|
|
44
|
+
return
|
|
45
|
+
started = time.perf_counter()
|
|
46
|
+
# The router changes the scope as it goes (a mount moves root_path,
|
|
47
|
+
# and a mounted app replaces "app"), so note what it was.
|
|
48
|
+
original = {"type": "http", "path": scope.get("path", ""), "root_path": scope.get("root_path", ""),
|
|
49
|
+
"method": scope.get("method", "GET"), "headers": scope.get("headers", [])}
|
|
50
|
+
outer_app = scope.get("app")
|
|
51
|
+
redaction.set_request_host(_header(scope, b"host"))
|
|
52
|
+
status = []
|
|
53
|
+
|
|
54
|
+
async def send_with_status(message):
|
|
55
|
+
if message.get("type") == "http.response.start":
|
|
56
|
+
status.append(message.get("status", 200))
|
|
57
|
+
await send(message)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
await self.app(scope, receive, send_with_status)
|
|
61
|
+
except Exception as exception:
|
|
62
|
+
_record(scope, original, outer_app, 500, started, exception)
|
|
63
|
+
raise
|
|
64
|
+
_record(scope, original, outer_app, status[0] if status else 500, started, None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _record(scope, original, outer_app, status, started, exception) -> None:
|
|
68
|
+
try:
|
|
69
|
+
http.record(original["method"], route_pattern(scope, original, outer_app), status, started,
|
|
70
|
+
unhandled=exception is not None, exception=exception)
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def route_pattern(scope, original, outer_app) -> Optional[str]:
|
|
76
|
+
"""The matched route's path format, with the paths of any mounts it sits
|
|
77
|
+
under; None when nothing matched or a mounted non-router app (such as
|
|
78
|
+
StaticFiles) answered."""
|
|
79
|
+
from starlette.routing import Mount
|
|
80
|
+
|
|
81
|
+
route = scope.get("route")
|
|
82
|
+
if route is None:
|
|
83
|
+
if scope.get("endpoint") is None:
|
|
84
|
+
return None
|
|
85
|
+
route = _match(getattr(outer_app, "routes", []), original)
|
|
86
|
+
return route
|
|
87
|
+
if isinstance(route, Mount):
|
|
88
|
+
return None
|
|
89
|
+
pattern = _effective_path(scope) or getattr(route, "path_format", None) or getattr(route, "path", None)
|
|
90
|
+
if not pattern:
|
|
91
|
+
return None
|
|
92
|
+
if scope.get("root_path", "") != original["root_path"]:
|
|
93
|
+
pattern = _mount_prefix(getattr(outer_app, "routes", []), _route_path(original)) + pattern
|
|
94
|
+
return pattern
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _effective_path(scope) -> Optional[str]:
|
|
98
|
+
"""FastAPI 0.14x includes routers lazily: the route in the scope is the
|
|
99
|
+
router's own, without the prefix include_router() added, and the path the
|
|
100
|
+
request matched is in FastAPI's part of the scope."""
|
|
101
|
+
fastapi_scope = scope.get("fastapi")
|
|
102
|
+
context = fastapi_scope.get("effective_route_context") if isinstance(fastapi_scope, dict) else None
|
|
103
|
+
if context is None:
|
|
104
|
+
return None
|
|
105
|
+
return getattr(getattr(context, "starlette_route", None), "path_format", None) or getattr(context, "path_format", None)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _route_path(scope) -> str:
|
|
109
|
+
path, root = scope["path"], scope["root_path"]
|
|
110
|
+
return path[len(root):] if root and path.startswith(root) else path
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _mount_prefix(routes, path: str) -> str:
|
|
114
|
+
from starlette.routing import Mount
|
|
115
|
+
|
|
116
|
+
for route in routes:
|
|
117
|
+
if isinstance(route, Mount):
|
|
118
|
+
match = route.path_regex.match(path)
|
|
119
|
+
if match:
|
|
120
|
+
return route.path + _mount_prefix(getattr(route, "routes", []) or [], "/" + match.groupdict().get("path", ""))
|
|
121
|
+
return ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _match(routes, scope, prefix: str = "") -> Optional[str]:
|
|
125
|
+
"""For a router that doesn't name the matched route in the scope: matches
|
|
126
|
+
the request again against the route table."""
|
|
127
|
+
from starlette.routing import Match, Mount
|
|
128
|
+
|
|
129
|
+
for route in routes:
|
|
130
|
+
try:
|
|
131
|
+
if isinstance(route, Mount):
|
|
132
|
+
match = route.path_regex.match(_route_path(scope))
|
|
133
|
+
if match and getattr(route, "routes", None):
|
|
134
|
+
child = dict(scope, path="/" + match.groupdict().get("path", ""), root_path="")
|
|
135
|
+
found = _match(route.routes, child, prefix + route.path)
|
|
136
|
+
if found:
|
|
137
|
+
return found
|
|
138
|
+
continue
|
|
139
|
+
result, _ = route.matches(scope)
|
|
140
|
+
if result == Match.FULL:
|
|
141
|
+
return prefix + (getattr(route, "path_format", None) or route.path)
|
|
142
|
+
except Exception:
|
|
143
|
+
continue
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _header(scope, name: bytes) -> Optional[str]:
|
|
148
|
+
for key, value in scope.get("headers") or []:
|
|
149
|
+
if key == name:
|
|
150
|
+
return value.decode("latin-1")
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _framework(app) -> tuple:
|
|
155
|
+
try:
|
|
156
|
+
import fastapi
|
|
157
|
+
|
|
158
|
+
if isinstance(app, fastapi.FastAPI):
|
|
159
|
+
return "fastapi", fastapi.__version__
|
|
160
|
+
except ImportError:
|
|
161
|
+
pass
|
|
162
|
+
import starlette
|
|
163
|
+
|
|
164
|
+
return "starlette", getattr(starlette, "__version__", None)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class StarletteRoutes:
|
|
168
|
+
def __init__(self, app):
|
|
169
|
+
self.app = app
|
|
170
|
+
|
|
171
|
+
def routes(self) -> list:
|
|
172
|
+
agent = deployangel.agent()
|
|
173
|
+
root = agent.root if agent else None
|
|
174
|
+
return list(_walk(self.app.routes, "", root))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _walk(routes, prefix: str, root: Optional[str]):
|
|
178
|
+
from starlette.routing import Mount
|
|
179
|
+
|
|
180
|
+
for route in _flatten(routes):
|
|
181
|
+
if isinstance(route, Mount):
|
|
182
|
+
if getattr(route, "routes", None):
|
|
183
|
+
yield from _walk(route.routes, prefix + route.path, root)
|
|
184
|
+
continue
|
|
185
|
+
if not getattr(route, "methods", None) or not getattr(route, "endpoint", None):
|
|
186
|
+
continue
|
|
187
|
+
endpoint = getattr(route, "endpoint", None)
|
|
188
|
+
module = getattr(endpoint, "__module__", None) or ""
|
|
189
|
+
if module.startswith(FRAMEWORK_MODULES):
|
|
190
|
+
continue
|
|
191
|
+
path = prefix + (getattr(route, "path_format", None) or route.path)
|
|
192
|
+
entry = {"controller": module, "action": getattr(endpoint, "__qualname__", None) or getattr(endpoint, "__name__", "")}
|
|
193
|
+
source = module_file(module, root)
|
|
194
|
+
if source:
|
|
195
|
+
entry["files"] = [source]
|
|
196
|
+
for method in sorted(getattr(route, "methods", None) or ["GET"]):
|
|
197
|
+
if method not in IGNORED_METHODS:
|
|
198
|
+
yield dict(entry, key=f"{method} {path}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _flatten(routes):
|
|
202
|
+
"""The routes as matched, with FastAPI 0.14x's lazily included routers
|
|
203
|
+
expanded (each with its include_router() prefix)."""
|
|
204
|
+
try:
|
|
205
|
+
from fastapi.routing import iter_route_contexts
|
|
206
|
+
except ImportError:
|
|
207
|
+
return list(routes)
|
|
208
|
+
from starlette.routing import Mount
|
|
209
|
+
|
|
210
|
+
flat = []
|
|
211
|
+
for route in routes:
|
|
212
|
+
if isinstance(route, Mount):
|
|
213
|
+
flat.append(route)
|
|
214
|
+
else:
|
|
215
|
+
flat.extend(iter_route_contexts([route]))
|
|
216
|
+
return flat
|