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 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 ""