visionary-sdk 0.4.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.
- visionary/__init__.py +29 -0
- visionary/_agent.py +270 -0
- visionary/_api.py +70 -0
- visionary/_config.py +173 -0
- visionary/_context.py +107 -0
- visionary/_d1.py +84 -0
- visionary/_db.py +256 -0
- visionary/_entry.py +138 -0
- visionary/_errors.py +183 -0
- visionary/_http.py +161 -0
- visionary/_node.py +379 -0
- visionary/_redact.py +106 -0
- visionary/_spans.py +225 -0
- visionary/_storage.py +512 -0
- visionary/_system.py +315 -0
- visionary/_writer.py +339 -0
- visionary/integrations/__init__.py +2 -0
- visionary/integrations/_common.py +81 -0
- visionary/integrations/asgi.py +155 -0
- visionary/integrations/django.py +312 -0
- visionary/integrations/flask.py +235 -0
- visionary/py.typed +0 -0
- visionary/schema/mysql/requests.sql +37 -0
- visionary/schema/mysql/system.sql +76 -0
- visionary/schema/mysql/telemetry.sql +52 -0
- visionary/schema/postgres/requests.sql +37 -0
- visionary/schema/postgres/system.sql +76 -0
- visionary/schema/postgres/telemetry.sql +52 -0
- visionary/schema/sqlite/requests.sql +37 -0
- visionary/schema/sqlite/system.sql +76 -0
- visionary/schema/sqlite/telemetry.sql +52 -0
- visionary_sdk-0.4.0.dist-info/METADATA +444 -0
- visionary_sdk-0.4.0.dist-info/RECORD +35 -0
- visionary_sdk-0.4.0.dist-info/WHEEL +4 -0
- visionary_sdk-0.4.0.dist-info/licenses/LICENSE +202 -0
visionary/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Visionary collector SDK.
|
|
2
|
+
|
|
3
|
+
Logs one row per HTTP request into ``api_logs``, the database and outbound
|
|
4
|
+
HTTP calls each request makes into ``spans``, unhandled and reported
|
|
5
|
+
exceptions into ``errors``, and samples host, process and runtime metrics
|
|
6
|
+
into ``system_metrics``, from background threads.
|
|
7
|
+
|
|
8
|
+
import visionary
|
|
9
|
+
agent = visionary.init() # from VISIONARY_* / D1_* env vars
|
|
10
|
+
|
|
11
|
+
Then add the integration for your framework (``visionary.integrations.django``,
|
|
12
|
+
``.asgi`` or ``.flask``) and call ``visionary.set_user(id)`` once the user is
|
|
13
|
+
known. See README.md and SPEC.md.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ._agent import Agent, get_agent, init
|
|
17
|
+
from ._api import capture_exception, instrument_sqlalchemy, span
|
|
18
|
+
from ._config import Config, from_env
|
|
19
|
+
from ._context import set_route, set_user
|
|
20
|
+
from ._db import instrument_connection, instrument_cursor
|
|
21
|
+
from ._http import instrument_http
|
|
22
|
+
|
|
23
|
+
__version__ = "0.4.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Agent", "Config", "capture_exception", "from_env", "get_agent", "init", "instrument_connection",
|
|
27
|
+
"instrument_cursor", "instrument_http", "instrument_sqlalchemy", "set_route", "set_user", "span",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
visionary/_agent.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import weakref
|
|
10
|
+
from typing import Callable, Optional
|
|
11
|
+
|
|
12
|
+
from . import _errors, _spans
|
|
13
|
+
from ._config import Config, from_env
|
|
14
|
+
from ._context import RequestInfo
|
|
15
|
+
from ._entry import Entry
|
|
16
|
+
from ._storage import Backend, open_backend
|
|
17
|
+
from ._system import HttpCounters, Recorder
|
|
18
|
+
from ._writer import Release, RequestWriter, Retention
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("visionary")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Agent:
|
|
24
|
+
"""Runs request logging, telemetry (spans, errors, releases) and system
|
|
25
|
+
metrics for one process.
|
|
26
|
+
|
|
27
|
+
With no database configured the agent is a no-op: it logs one warning
|
|
28
|
+
and every method does nothing. It never raises into the host app once
|
|
29
|
+
constructed.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, config: Optional[Config] = None, *, backend: Optional[Backend] = None) -> None:
|
|
33
|
+
cfg = config if config is not None else from_env()
|
|
34
|
+
self.config = cfg
|
|
35
|
+
self.http = HttpCounters()
|
|
36
|
+
self._backend: Optional[Backend] = None
|
|
37
|
+
self._requests: Optional[RequestWriter] = None
|
|
38
|
+
self._system: Optional[Recorder] = None
|
|
39
|
+
self._retention: Optional[Retention] = None
|
|
40
|
+
self._error_limit = _errors.RateLimiter()
|
|
41
|
+
self.spans_on = False
|
|
42
|
+
self.errors_on = False
|
|
43
|
+
self._started = False
|
|
44
|
+
self._closed = False
|
|
45
|
+
self._lock = threading.Lock()
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
self._backend = backend if backend is not None else open_backend(cfg)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logger.warning("%s; collection is off", e)
|
|
51
|
+
return
|
|
52
|
+
if self._backend is None:
|
|
53
|
+
logger.warning("visionary: no database configured (set VISIONARY_DATABASE_URL or D1_*); collection is off")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
instance = cfg.instance or socket.gethostname()
|
|
57
|
+
self.instance = instance
|
|
58
|
+
if cfg.log_requests or cfg.spans or cfg.errors or cfg.version:
|
|
59
|
+
# One writer for api_logs and the telemetry tables.
|
|
60
|
+
self._requests = RequestWriter(cfg, self._backend, logger, instance, _errors.runtime())
|
|
61
|
+
self.spans_on = cfg.spans
|
|
62
|
+
self.errors_on = cfg.errors
|
|
63
|
+
if cfg.system_metrics and cfg.system_interval > 0:
|
|
64
|
+
self._system = Recorder(cfg, instance, self._backend, self.http, logger)
|
|
65
|
+
retention = {}
|
|
66
|
+
if self._requests is not None and cfg.log_requests:
|
|
67
|
+
retention["requests"] = cfg.log_retention_days
|
|
68
|
+
if self.spans_on:
|
|
69
|
+
retention["spans"] = cfg.log_retention_days
|
|
70
|
+
if self.errors_on:
|
|
71
|
+
retention["errors"] = cfg.log_retention_days
|
|
72
|
+
if self._system is not None:
|
|
73
|
+
retention["system"] = cfg.system_retention_days
|
|
74
|
+
self._retention = Retention(self._backend, logger, retention)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def enabled(self) -> bool:
|
|
78
|
+
"""True when anything is collected (a database and at least one feature)."""
|
|
79
|
+
return self._requests is not None or self._system is not None
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def logging_requests(self) -> bool:
|
|
83
|
+
return self._requests is not None and self.config.log_requests
|
|
84
|
+
|
|
85
|
+
def should_log(self, path: str) -> bool:
|
|
86
|
+
"""Whether a request to path gets an api_logs row (integration API)."""
|
|
87
|
+
return self.logging_requests and path.startswith(self.config.path_prefix)
|
|
88
|
+
|
|
89
|
+
def record(self, entry: Entry) -> None:
|
|
90
|
+
"""Queue a captured request (integration API). Never blocks."""
|
|
91
|
+
if self.logging_requests and not self._closed:
|
|
92
|
+
self._requests.log(entry)
|
|
93
|
+
|
|
94
|
+
def new_trace(self, request_id: str, t0: float) -> Optional[_spans.Trace]:
|
|
95
|
+
"""The span collector for a logged request (integration API); t0 is
|
|
96
|
+
its time.perf_counter() start."""
|
|
97
|
+
if not self.spans_on:
|
|
98
|
+
return None
|
|
99
|
+
return _spans.Trace(request_id, t0, self.config.span_max)
|
|
100
|
+
|
|
101
|
+
def finish_trace(self, entry: Entry, trace: Optional[_spans.Trace]) -> None:
|
|
102
|
+
"""Copy call totals and, for slow or failed requests, the spans onto
|
|
103
|
+
the entry (integration API, after status and duration are set)."""
|
|
104
|
+
if trace is None:
|
|
105
|
+
return
|
|
106
|
+
slow = entry.duration * 1000.0 >= self.config.span_threshold_ms or entry.status >= 500
|
|
107
|
+
entry.spans = trace.close(slow)
|
|
108
|
+
if _spans.db_visible:
|
|
109
|
+
entry.db_count, entry.db_ms = trace.db_count, trace.db_ms
|
|
110
|
+
if _spans.http_visible:
|
|
111
|
+
entry.http_count, entry.http_ms = trace.http_count, trace.http_ms
|
|
112
|
+
|
|
113
|
+
def record_span(self, span: _spans.Span) -> None:
|
|
114
|
+
"""Queue a span recorded outside a request. Never blocks."""
|
|
115
|
+
if self.spans_on and not self._closed:
|
|
116
|
+
self._requests.log(span)
|
|
117
|
+
|
|
118
|
+
def capture_error(self, exc: BaseException, handled: bool, info: Optional[RequestInfo] = None) -> None:
|
|
119
|
+
"""Queue an errors row for exc, rate limited per fingerprint. Never
|
|
120
|
+
raises."""
|
|
121
|
+
if not self.errors_on or self._closed or exc is None:
|
|
122
|
+
return
|
|
123
|
+
try:
|
|
124
|
+
ev = _errors.build(exc, handled, info)
|
|
125
|
+
if self._error_limit.allow(ev.fingerprint):
|
|
126
|
+
self._requests.log(ev)
|
|
127
|
+
except Exception:
|
|
128
|
+
logger.exception("visionary: capturing an error failed")
|
|
129
|
+
|
|
130
|
+
def default_db_stats(self, fn: Callable[[], Optional[dict]]) -> None:
|
|
131
|
+
"""Fill the db_* columns from fn unless the app set config.db_stats
|
|
132
|
+
(integration API). Works before or after start()."""
|
|
133
|
+
if self._system is not None and self.config.db_stats is None:
|
|
134
|
+
self._system.set_default_db_stats(fn)
|
|
135
|
+
|
|
136
|
+
def start(self) -> "Agent":
|
|
137
|
+
"""Launch the background threads. Tables are created on them, so a slow
|
|
138
|
+
or unreachable database never delays startup. Safe to call twice."""
|
|
139
|
+
with self._lock:
|
|
140
|
+
if self._started or self._closed or not self.enabled:
|
|
141
|
+
return self
|
|
142
|
+
self._started = True
|
|
143
|
+
if self._requests is not None:
|
|
144
|
+
self._requests.start()
|
|
145
|
+
if self.config.version:
|
|
146
|
+
self._requests.log(Release(time.time(), _sdk_version(), _errors.runtime()))
|
|
147
|
+
if self.logging_requests:
|
|
148
|
+
logger.info("visionary: logging requests (path_prefix=%r, bodies=%s, retention_days=%d)",
|
|
149
|
+
self.config.path_prefix, self.config.capture_bodies, self.config.log_retention_days)
|
|
150
|
+
if self.spans_on or self.errors_on:
|
|
151
|
+
_spans.set_active(self)
|
|
152
|
+
if self.spans_on and self.config.instrument_http:
|
|
153
|
+
from ._http import instrument_http
|
|
154
|
+
|
|
155
|
+
instrument_http()
|
|
156
|
+
if self._system is not None:
|
|
157
|
+
self._system.start()
|
|
158
|
+
logger.info("visionary: recording system metrics every %ss", self.config.system_interval)
|
|
159
|
+
if self._retention is not None and self._retention.needed:
|
|
160
|
+
self._retention.start()
|
|
161
|
+
_track_for_fork(self)
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
def close(self, timeout: float = 10) -> None:
|
|
165
|
+
"""Flush queued requests and buffered samples, waiting at most timeout
|
|
166
|
+
seconds in total."""
|
|
167
|
+
with self._lock:
|
|
168
|
+
if self._closed:
|
|
169
|
+
return
|
|
170
|
+
self._closed = True
|
|
171
|
+
started = self._started
|
|
172
|
+
_spans.clear_active(self)
|
|
173
|
+
idle = True
|
|
174
|
+
if started:
|
|
175
|
+
deadline = _Deadline(timeout)
|
|
176
|
+
if self._retention is not None:
|
|
177
|
+
self._retention.close()
|
|
178
|
+
if self._requests is not None:
|
|
179
|
+
idle = self._requests.close(deadline.left()) and idle
|
|
180
|
+
if self._system is not None:
|
|
181
|
+
idle = self._system.close(deadline.left()) and idle
|
|
182
|
+
# A worker still busy (e.g. stuck connecting) keeps its connection;
|
|
183
|
+
# it is a daemon thread and goes away with the process.
|
|
184
|
+
if idle and self._backend is not None:
|
|
185
|
+
try:
|
|
186
|
+
self._backend.close()
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
def _after_fork(self) -> None:
|
|
191
|
+
# Threads don't survive fork (e.g. gunicorn --preload): restart them
|
|
192
|
+
# in the child with fresh queues and connections.
|
|
193
|
+
if not self._started or self._closed:
|
|
194
|
+
return
|
|
195
|
+
self._lock = threading.Lock()
|
|
196
|
+
self.http = HttpCounters()
|
|
197
|
+
if self._backend is not None:
|
|
198
|
+
self._backend.after_fork()
|
|
199
|
+
if self._system is not None:
|
|
200
|
+
self._system._http = self.http
|
|
201
|
+
self._system.restart_after_fork()
|
|
202
|
+
if self._requests is not None:
|
|
203
|
+
self._requests.restart_after_fork()
|
|
204
|
+
if self._retention is not None and self._retention.needed:
|
|
205
|
+
self._retention.restart_after_fork()
|
|
206
|
+
|
|
207
|
+
def __enter__(self) -> "Agent":
|
|
208
|
+
return self.start()
|
|
209
|
+
|
|
210
|
+
def __exit__(self, *exc: object) -> None:
|
|
211
|
+
self.close()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _sdk_version() -> str:
|
|
215
|
+
from . import __version__
|
|
216
|
+
|
|
217
|
+
return __version__
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class _Deadline:
|
|
221
|
+
def __init__(self, seconds: float) -> None:
|
|
222
|
+
self._end = time.monotonic() + max(0.0, seconds)
|
|
223
|
+
|
|
224
|
+
def left(self) -> float:
|
|
225
|
+
return max(0.0, self._end - time.monotonic())
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
_fork_agents: "weakref.WeakSet[Agent]" = weakref.WeakSet()
|
|
229
|
+
_fork_hooked = False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _track_for_fork(agent: Agent) -> None:
|
|
233
|
+
global _fork_hooked
|
|
234
|
+
_fork_agents.add(agent)
|
|
235
|
+
if not _fork_hooked and hasattr(os, "register_at_fork"):
|
|
236
|
+
os.register_at_fork(after_in_child=lambda: [a._after_fork() for a in list(_fork_agents)])
|
|
237
|
+
_fork_hooked = True
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
_default: Optional[Agent] = None
|
|
241
|
+
_default_lock = threading.Lock()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def init(config: Optional[Config] = None) -> Agent:
|
|
245
|
+
"""Build the process-wide agent (from the environment unless config is
|
|
246
|
+
given), start it and flush it at exit. Returns the existing agent when
|
|
247
|
+
called again. Never raises: on any problem it returns a no-op agent."""
|
|
248
|
+
global _default
|
|
249
|
+
with _default_lock:
|
|
250
|
+
if _default is not None:
|
|
251
|
+
return _default
|
|
252
|
+
try:
|
|
253
|
+
agent = Agent(config)
|
|
254
|
+
agent.start()
|
|
255
|
+
except Exception:
|
|
256
|
+
logger.exception("visionary: init failed; collection is off")
|
|
257
|
+
agent = Agent(Config(log_requests=False, system_metrics=False, spans=False, errors=False),
|
|
258
|
+
backend=_NoBackend())
|
|
259
|
+
atexit.register(agent.close)
|
|
260
|
+
_default = agent
|
|
261
|
+
return agent
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_agent() -> Optional[Agent]:
|
|
265
|
+
"""The agent set up by init(), if any."""
|
|
266
|
+
return _default
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class _NoBackend(Backend):
|
|
270
|
+
"""Placeholder for the disabled agent built after a failed init."""
|
visionary/_api.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Public telemetry API: manual spans and handled errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from . import _agent, _spans
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class span:
|
|
12
|
+
"""Time a block as a span (sync or async)::
|
|
13
|
+
|
|
14
|
+
with visionary.span("cache", "GET session") as s:
|
|
15
|
+
value = redis.get(key)
|
|
16
|
+
s.set_row_count(1)
|
|
17
|
+
|
|
18
|
+
``kind`` is ``db``, ``http``, ``cache``, ``queue`` or ``custom`` (anything
|
|
19
|
+
else becomes ``custom``). An exception leaving the block marks the span as
|
|
20
|
+
failed and propagates unchanged. Does nothing when spans are off.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__slots__ = ("_args", "_span")
|
|
24
|
+
|
|
25
|
+
def __init__(self, kind: str, name: str, target: Optional[str] = None, detail: Optional[str] = None) -> None:
|
|
26
|
+
self._args = (kind, name, target, detail)
|
|
27
|
+
self._span: Optional[_spans.Span] = None
|
|
28
|
+
|
|
29
|
+
def set_error(self, error: Any) -> None:
|
|
30
|
+
"""Mark the span failed without raising (e.g. a cache miss you treat as an error)."""
|
|
31
|
+
if self._span is not None:
|
|
32
|
+
self._span.set_error(error)
|
|
33
|
+
|
|
34
|
+
def set_row_count(self, n: int) -> None:
|
|
35
|
+
if self._span is not None and n is not None and n >= 0:
|
|
36
|
+
self._span.row_count = int(n)
|
|
37
|
+
|
|
38
|
+
def __enter__(self) -> "span":
|
|
39
|
+
self._span = _spans.start(*self._args)
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
43
|
+
if self._span is not None:
|
|
44
|
+
self._span.finish(error=exc)
|
|
45
|
+
|
|
46
|
+
async def __aenter__(self) -> "span":
|
|
47
|
+
return self.__enter__()
|
|
48
|
+
|
|
49
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
50
|
+
self.__exit__(exc_type, exc, tb)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def capture_exception(exc: Optional[BaseException] = None) -> None:
|
|
54
|
+
"""Record a handled exception (``handled = 1``): ``exc``, or the one being
|
|
55
|
+
handled when called inside ``except``. Inside a request it is linked to
|
|
56
|
+
that request. Never raises."""
|
|
57
|
+
if exc is None:
|
|
58
|
+
exc = sys.exc_info()[1]
|
|
59
|
+
if exc is None:
|
|
60
|
+
return
|
|
61
|
+
agent = _spans.active() or _agent.get_agent()
|
|
62
|
+
if agent is not None:
|
|
63
|
+
agent.capture_error(exc, True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def instrument_sqlalchemy(engine: Any) -> Any:
|
|
67
|
+
"""Record the statements of an SQLAlchemy ``Engine``/``AsyncEngine`` as spans."""
|
|
68
|
+
from ._db import instrument_sqlalchemy as instrument
|
|
69
|
+
|
|
70
|
+
return instrument(engine)
|
visionary/_config.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
LookupUser = Callable[[str], Optional[Tuple[Optional[str], Optional[str]]]]
|
|
9
|
+
DBStatsFunc = Callable[[], Dict[str, Any]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Config:
|
|
14
|
+
"""Everything the agent needs. ``from_env()`` fills it from ``VISIONARY_*``.
|
|
15
|
+
|
|
16
|
+
Storage is chosen in this order: ``connection_factory`` + ``dialect``,
|
|
17
|
+
then ``database_url``, then the ``d1_*`` credentials.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
database_url: str = ""
|
|
21
|
+
d1_account_id: str = ""
|
|
22
|
+
d1_database_id: str = ""
|
|
23
|
+
d1_api_token: str = ""
|
|
24
|
+
d1_api_base: str = ""
|
|
25
|
+
# A zero-argument callable returning a DB-API connection the SDK may keep
|
|
26
|
+
# for itself (it reconnects through it after errors and closes it at
|
|
27
|
+
# shutdown). ``dialect`` is "sqlite", "postgres" or "mysql".
|
|
28
|
+
connection_factory: Optional[Callable[[], Any]] = None
|
|
29
|
+
dialect: str = ""
|
|
30
|
+
|
|
31
|
+
instance: str = ""
|
|
32
|
+
|
|
33
|
+
log_requests: bool = True
|
|
34
|
+
path_prefix: str = ""
|
|
35
|
+
capture_bodies: bool = True
|
|
36
|
+
max_body_bytes: int = 8192
|
|
37
|
+
queue_size: int = 5000
|
|
38
|
+
batch_size: int = 50
|
|
39
|
+
flush_interval: float = 3.0
|
|
40
|
+
log_retention_days: int = 30
|
|
41
|
+
redact_headers: List[str] = field(default_factory=list)
|
|
42
|
+
redact_fields: List[str] = field(default_factory=list)
|
|
43
|
+
redact_request_fields: List[str] = field(default_factory=list)
|
|
44
|
+
error_code_field: str = "error.code"
|
|
45
|
+
error_key_field: str = "error.key"
|
|
46
|
+
# Runs on the writer thread, once per distinct user per batch, for users
|
|
47
|
+
# missing a handle or role. Return (handle, role) or None.
|
|
48
|
+
lookup_user: Optional[LookupUser] = None
|
|
49
|
+
|
|
50
|
+
system_metrics: bool = True
|
|
51
|
+
system_interval: float = 15.0
|
|
52
|
+
system_retention_days: int = 30
|
|
53
|
+
# Returns pool stats: total, idle, in_use, max, acquires, empty_acquires,
|
|
54
|
+
# acquire_wait_ms (the last three cumulative).
|
|
55
|
+
db_stats: Optional[DBStatsFunc] = None
|
|
56
|
+
# Filesystem reported in disk_* columns (default: the working directory).
|
|
57
|
+
disk_path: str = ""
|
|
58
|
+
|
|
59
|
+
# Telemetry (SPEC "Telemetry"). service and version label every row;
|
|
60
|
+
# version defaults to the platform's commit variable (see from_env).
|
|
61
|
+
service: str = ""
|
|
62
|
+
version: str = ""
|
|
63
|
+
spans: bool = True
|
|
64
|
+
span_threshold_ms: float = 100.0
|
|
65
|
+
span_max: int = 100
|
|
66
|
+
errors: bool = True
|
|
67
|
+
# Patch requests, httpx and urllib3 at start (same as instrument_http()).
|
|
68
|
+
instrument_http: bool = False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def from_env(environ: Optional[Dict[str, str]] = None) -> Config:
|
|
72
|
+
"""Build a Config from environment variables (see SPEC.md "Environment")."""
|
|
73
|
+
env = os.environ if environ is None else environ
|
|
74
|
+
|
|
75
|
+
def s(key: str, default: str = "") -> str:
|
|
76
|
+
v = env.get(key, "").strip()
|
|
77
|
+
return v or default
|
|
78
|
+
|
|
79
|
+
return Config(
|
|
80
|
+
database_url=s("VISIONARY_DATABASE_URL"),
|
|
81
|
+
d1_account_id=s("D1_ACCOUNT_ID"),
|
|
82
|
+
d1_database_id=s("D1_DATABASE_ID"),
|
|
83
|
+
d1_api_token=s("D1_API_TOKEN"),
|
|
84
|
+
d1_api_base=s("D1_API_BASE"),
|
|
85
|
+
instance=s("VISIONARY_INSTANCE"),
|
|
86
|
+
log_requests=parse_bool(s("VISIONARY_LOG_REQUESTS"), True),
|
|
87
|
+
path_prefix=s("VISIONARY_LOG_PATH_PREFIX"),
|
|
88
|
+
capture_bodies=parse_bool(s("VISIONARY_LOG_BODIES"), True),
|
|
89
|
+
max_body_bytes=parse_int(s("VISIONARY_LOG_MAX_BODY_BYTES"), 8192),
|
|
90
|
+
log_retention_days=parse_int(s("VISIONARY_LOG_RETENTION_DAYS"), 30),
|
|
91
|
+
redact_headers=parse_list(s("VISIONARY_LOG_REDACT_HEADERS")),
|
|
92
|
+
redact_fields=parse_list(s("VISIONARY_LOG_REDACT_FIELDS")),
|
|
93
|
+
redact_request_fields=parse_list(s("VISIONARY_LOG_REDACT_REQUEST_FIELDS")),
|
|
94
|
+
error_code_field=s("VISIONARY_LOG_ERROR_CODE_FIELD", "error.code"),
|
|
95
|
+
error_key_field=s("VISIONARY_LOG_ERROR_KEY_FIELD", "error.key"),
|
|
96
|
+
system_metrics=parse_bool(s("VISIONARY_SYSTEM_METRICS"), True),
|
|
97
|
+
system_interval=parse_duration(s("VISIONARY_SYSTEM_INTERVAL"), 15.0),
|
|
98
|
+
system_retention_days=parse_int(s("VISIONARY_SYSTEM_RETENTION_DAYS"), 30),
|
|
99
|
+
service=s("VISIONARY_SERVICE"),
|
|
100
|
+
version=release_from_env(env),
|
|
101
|
+
spans=parse_bool(s("VISIONARY_SPANS"), True),
|
|
102
|
+
span_threshold_ms=float(parse_int(s("VISIONARY_SPAN_THRESHOLD_MS"), 100)),
|
|
103
|
+
span_max=parse_int(s("VISIONARY_SPAN_MAX"), 100),
|
|
104
|
+
errors=parse_bool(s("VISIONARY_ERRORS"), True),
|
|
105
|
+
instrument_http=parse_bool(s("VISIONARY_INSTRUMENT_HTTP"), False),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Commit variables set by common platforms, in the order SPEC.md lists them.
|
|
110
|
+
_COMMIT_VARS = (
|
|
111
|
+
"RAILWAY_GIT_COMMIT_SHA", "RENDER_GIT_COMMIT", "VERCEL_GIT_COMMIT_SHA", "HEROKU_SLUG_COMMIT",
|
|
112
|
+
"SOURCE_VERSION", "GIT_COMMIT", "GITHUB_SHA", "K_REVISION",
|
|
113
|
+
)
|
|
114
|
+
_COMMIT_HASH = re.compile(r"^[0-9a-fA-F]{13,64}$")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def release_from_env(env: Dict[str, str]) -> str:
|
|
118
|
+
"""VISIONARY_RELEASE, else the first platform commit variable set; commit
|
|
119
|
+
hashes are shortened to 12 characters."""
|
|
120
|
+
for key in ("VISIONARY_RELEASE",) + _COMMIT_VARS:
|
|
121
|
+
v = env.get(key, "").strip()
|
|
122
|
+
if v:
|
|
123
|
+
return v[:12] if _COMMIT_HASH.match(v) else v
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_TRUE = {"true", "1", "yes"}
|
|
128
|
+
_FALSE = {"false", "0", "no"}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_bool(value: str, default: bool) -> bool:
|
|
132
|
+
v = value.strip().lower()
|
|
133
|
+
if v in _TRUE:
|
|
134
|
+
return True
|
|
135
|
+
if v in _FALSE:
|
|
136
|
+
return False
|
|
137
|
+
return default
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def parse_int(value: str, default: int) -> int:
|
|
141
|
+
try:
|
|
142
|
+
n = int(value.strip())
|
|
143
|
+
except ValueError:
|
|
144
|
+
return default
|
|
145
|
+
return n if n >= 0 else default
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_list(value: str) -> List[str]:
|
|
149
|
+
return [p.strip() for p in value.split(",") if p.strip()]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_DURATION_PART = re.compile(r"(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)")
|
|
153
|
+
_UNITS = {"ns": 1e-9, "us": 1e-6, "µs": 1e-6, "ms": 1e-3, "s": 1.0, "m": 60.0, "h": 3600.0}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_duration(value: str, default: float) -> float:
|
|
157
|
+
"""Seconds from "15", "15s", "500ms", "1m30s" (Go duration syntax)."""
|
|
158
|
+
v = value.strip()
|
|
159
|
+
if not v:
|
|
160
|
+
return default
|
|
161
|
+
try:
|
|
162
|
+
return float(int(v))
|
|
163
|
+
except ValueError:
|
|
164
|
+
pass
|
|
165
|
+
pos, total = 0, 0.0
|
|
166
|
+
for m in _DURATION_PART.finditer(v):
|
|
167
|
+
if m.start() != pos:
|
|
168
|
+
return default
|
|
169
|
+
total += float(m.group(1)) * _UNITS[m.group(2)]
|
|
170
|
+
pos = m.end()
|
|
171
|
+
if pos != len(v) or pos == 0:
|
|
172
|
+
return default
|
|
173
|
+
return total
|
visionary/_context.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from contextvars import ContextVar, Token
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Annotations:
|
|
9
|
+
"""What app code says about the current request.
|
|
10
|
+
|
|
11
|
+
Integrations put one instance in a contextvar at request start. Because
|
|
12
|
+
copied contexts (thread pools, asyncio tasks) share the same object,
|
|
13
|
+
set_user works from wherever the request's code runs.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__slots__ = ("_lock", "user_id", "user_handle", "user_role", "route", "trace", "request")
|
|
17
|
+
|
|
18
|
+
def __init__(self, trace: Any = None, request: Optional["RequestInfo"] = None) -> None:
|
|
19
|
+
self._lock = threading.Lock()
|
|
20
|
+
self.user_id = ""
|
|
21
|
+
self.user_handle = ""
|
|
22
|
+
self.user_role = ""
|
|
23
|
+
self.route = ""
|
|
24
|
+
self.trace = trace # _spans.Trace, or None when spans are off
|
|
25
|
+
self.request = request
|
|
26
|
+
|
|
27
|
+
def set_user(self, id: Any, handle: Optional[str], role: Optional[str]) -> None:
|
|
28
|
+
with self._lock:
|
|
29
|
+
self.user_id = "" if id is None else str(id)
|
|
30
|
+
self.user_handle = handle or ""
|
|
31
|
+
self.user_role = role or ""
|
|
32
|
+
|
|
33
|
+
def user(self) -> tuple:
|
|
34
|
+
with self._lock:
|
|
35
|
+
return self.user_id, self.user_handle, self.user_role
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RequestInfo:
|
|
39
|
+
"""What an error row says about the request it happened in. The route is
|
|
40
|
+
resolved lazily, since routers only match it during the call."""
|
|
41
|
+
|
|
42
|
+
__slots__ = ("request_id", "method", "path", "_route", "_user")
|
|
43
|
+
|
|
44
|
+
def __init__(self, request_id: Optional[str], method: str, path: str,
|
|
45
|
+
route: Optional[Callable[[], str]] = None, user: Optional[Callable[[], str]] = None) -> None:
|
|
46
|
+
self.request_id = request_id
|
|
47
|
+
self.method = method
|
|
48
|
+
self.path = path
|
|
49
|
+
self._route = route
|
|
50
|
+
self._user = user
|
|
51
|
+
|
|
52
|
+
def route(self, ann: Optional[Annotations] = None) -> str:
|
|
53
|
+
if ann is not None and ann.route:
|
|
54
|
+
return ann.route
|
|
55
|
+
try:
|
|
56
|
+
return self._route() if self._route is not None else ""
|
|
57
|
+
except Exception:
|
|
58
|
+
return ""
|
|
59
|
+
|
|
60
|
+
def user_id(self, ann: Optional[Annotations] = None) -> str:
|
|
61
|
+
if ann is not None and ann.user_id:
|
|
62
|
+
return ann.user_id
|
|
63
|
+
try:
|
|
64
|
+
return self._user() if self._user is not None else ""
|
|
65
|
+
except Exception:
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_current: ContextVar[Optional[Annotations]] = ContextVar("visionary_request", default=None)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def begin(trace: Any = None, request: Optional[RequestInfo] = None) -> tuple:
|
|
73
|
+
ann = Annotations(trace, request)
|
|
74
|
+
return ann, _current.set(ann)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def end(token: Token) -> None:
|
|
78
|
+
try:
|
|
79
|
+
_current.reset(token)
|
|
80
|
+
except ValueError:
|
|
81
|
+
# Reset from a different context than the one that set it (some
|
|
82
|
+
# frameworks run hooks in copies); clearing is equivalent here.
|
|
83
|
+
_current.set(None)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def current() -> Optional[Annotations]:
|
|
87
|
+
return _current.get()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def set_user(id: Any, handle: Optional[str] = None, role: Optional[str] = None) -> None:
|
|
91
|
+
"""Attach the signed-in user to the request being logged.
|
|
92
|
+
|
|
93
|
+
Call it from auth middleware or a view once the user is known. Only
|
|
94
|
+
``id`` is required. Outside a request handled by a Visionary integration
|
|
95
|
+
this does nothing.
|
|
96
|
+
"""
|
|
97
|
+
ann = _current.get()
|
|
98
|
+
if ann is not None:
|
|
99
|
+
ann.set_user(id, handle, role)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def set_route(pattern: str) -> None:
|
|
103
|
+
"""Record the route pattern (e.g. ``/users/{id}``) for routers the
|
|
104
|
+
integration can't see. No-op outside a request."""
|
|
105
|
+
ann = _current.get()
|
|
106
|
+
if ann is not None:
|
|
107
|
+
ann.route = pattern or ""
|