varanus 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev4__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.
Files changed (58) hide show
  1. varanus/client/apps.py +1 -1
  2. varanus/client/client.py +100 -47
  3. varanus/client/context.py +11 -11
  4. varanus/client/loggers.py +4 -2
  5. varanus/client/middleware.py +15 -19
  6. varanus/client/transport/base.py +1 -1
  7. varanus/client/transport/database.py +41 -14
  8. varanus/client/transport/http.py +55 -9
  9. varanus/events.py +33 -6
  10. varanus/{server/search → search}/__init__.py +3 -2
  11. varanus/search/base.py +159 -0
  12. varanus/search/fields.py +189 -0
  13. varanus/search/templates/search/daterange.html +11 -0
  14. varanus/search/templates/search/filter.html +1 -0
  15. varanus/search/templates/search/hidden.html +12 -0
  16. varanus/search/templates/search/multifacet.html +6 -0
  17. varanus/search/templates/search/search.html +23 -0
  18. varanus/search/utils.py +38 -0
  19. varanus/server/admin.py +40 -14
  20. varanus/server/context_processors.py +6 -4
  21. varanus/server/migrations/0001_initial.py +157 -60
  22. varanus/server/models.py +87 -16
  23. varanus/server/router.py +3 -0
  24. varanus/server/settings.py +32 -0
  25. varanus/server/templates/base.html +2 -2
  26. varanus/server/templates/dashboard.html +7 -0
  27. varanus/server/templates/site/base.html +40 -32
  28. varanus/server/templates/site/details/error.html +1 -1
  29. varanus/server/templates/site/details/metric.html +4 -4
  30. varanus/server/templates/site/details/node_env.html +24 -0
  31. varanus/server/templates/site/details/{node.html → node_packages.html} +1 -1
  32. varanus/server/templates/site/details/node_settings.html +24 -0
  33. varanus/server/templates/site/details/query.html +17 -0
  34. varanus/server/templates/site/details/request.html +27 -17
  35. varanus/server/templates/site/errors.html +18 -4
  36. varanus/server/templates/site/logs.html +20 -5
  37. varanus/server/templates/site/metrics.html +17 -5
  38. varanus/server/templates/site/overview.html +10 -6
  39. varanus/server/templates/site/queries.html +38 -10
  40. varanus/server/templates/site/requests.html +30 -8
  41. varanus/server/templatetags/varanus.py +7 -0
  42. varanus/server/urls.py +19 -2
  43. varanus/server/utils.py +8 -0
  44. varanus/server/views/api.py +20 -12
  45. varanus/server/views/dashboard.py +21 -2
  46. varanus/server/views/site.py +119 -30
  47. {varanus-0.1.0.dev2.dist-info → varanus-0.1.0.dev4.dist-info}/METADATA +1 -1
  48. varanus-0.1.0.dev4.dist-info/RECORD +73 -0
  49. {varanus-0.1.0.dev2.dist-info → varanus-0.1.0.dev4.dist-info}/WHEEL +1 -1
  50. varanus/server/migrations/0002_context_node_error_node_log_node_metric_node_and_more.py +0 -79
  51. varanus/server/migrations/0003_alter_log_level.py +0 -28
  52. varanus/server/search/base.py +0 -118
  53. varanus/server/search/date.py +0 -33
  54. varanus/server/search/facet.py +0 -63
  55. varanus/server/templates/search/daterange.html +0 -10
  56. varanus/server/templates/search/multifacet.html +0 -11
  57. varanus-0.1.0.dev2.dist-info/RECORD +0 -69
  58. {varanus-0.1.0.dev2.dist-info → varanus-0.1.0.dev4.dist-info}/entry_points.txt +0 -0
varanus/client/apps.py CHANGED
@@ -5,4 +5,4 @@ class VaranusClient(AppConfig):
5
5
  name = "varanus.client"
6
6
 
7
7
  def ready(self):
8
- print("VaranusClient.ready")
8
+ pass
varanus/client/client.py CHANGED
@@ -1,6 +1,7 @@
1
+ import os
1
2
  import platform
2
3
  from importlib.metadata import distributions
3
- from typing import Union
4
+ from typing import Iterable
4
5
  from urllib.parse import urlsplit
5
6
 
6
7
  from varanus.events import Context, NodeInfo
@@ -19,64 +20,96 @@ def install_query_logger(logger):
19
20
  return handler
20
21
 
21
22
 
23
+ def resolve_include_exclude(
24
+ items: Iterable[str],
25
+ include: Iterable[str] | bool,
26
+ exclude: Iterable[str] | None,
27
+ ) -> set[str]:
28
+ if not include:
29
+ return set()
30
+
31
+ if include is True:
32
+ resolved = set(items)
33
+ else:
34
+ resolved = set(items).intersection(include)
35
+
36
+ if exclude is not None:
37
+ resolved.difference_update(exclude)
38
+
39
+ return resolved
40
+
41
+
22
42
  class VaranusClient:
23
- environment: str | None
24
- transport: BaseTransport
25
-
26
- request_attr: str
27
- logger_name: str
28
- tags: dict
29
-
30
- include_headers: Union[list, bool, None]
31
- exclude_headers: list | None
32
- sensitive_headers = set(
33
- [
34
- "authorization",
35
- "cookie",
36
- "proxy-authorization",
37
- ]
38
- )
43
+ sensitive_headers = set(["authorization", "cookie", "proxy-authorization"])
44
+ sensitive_settings = set(["DATABASES"])
45
+ sensitive_env = set(["PGPASSWORD"])
39
46
 
40
47
  scheme_transports = {
41
- "test": "varanus.client.transport.test.TestTransport",
42
- "http": "varanus.client.transport.http.HttpTransport",
43
- "https": "varanus.client.transport.http.HttpTransport",
48
+ "http": "varanus.client.transport.http.ThreadedHttpTransport",
49
+ "https": "varanus.client.transport.http.ThreadedHttpTransport",
44
50
  "db": "varanus.client.transport.database.ModelTransport",
45
51
  }
46
52
 
47
- send_all: bool
48
53
  configured = False
49
54
 
50
55
  def setup(
51
56
  self,
52
- dsn,
53
- request_attr="varanus",
54
- environment=None,
55
- transport_class=None,
56
- logger_name="varanus.request",
57
- tags=None,
58
- include_headers=None,
59
- exclude_headers=None,
57
+ dsn: str,
58
+ environment: str,
59
+ node: str | None = None,
60
+ transport_class: str | type[BaseTransport] | None = None,
61
+ request_attr: str = "varanus",
62
+ logger_name: str = "varanus.request",
63
+ tags: dict | None = None,
64
+ include_headers: Iterable[str] | bool = False,
65
+ exclude_headers: Iterable[str] | None = None,
66
+ include_settings: Iterable[str] | bool = False,
67
+ exclude_settings: Iterable[str] | None = None,
68
+ include_default_settings: bool = False,
69
+ include_env: Iterable[str] | bool = False,
70
+ exclude_env: Iterable[str] | None = None,
60
71
  log_queries: bool | int = False,
61
- log_query_params=False,
62
- query_metrics=False,
63
- send_all=False,
64
- install=None,
72
+ log_query_params: bool = False,
73
+ log_query_stack: bool = False,
74
+ query_metrics: bool | str = False,
75
+ send_all: bool = False,
76
+ install: list | None = None,
65
77
  ):
66
78
  url = urlsplit(dsn)
67
79
  self.environment = environment
80
+ self.node = node or platform.node()
68
81
  if transport_class is None:
69
- transport_class = self.scheme_transports.get(url.scheme)
70
- if transport_class is None:
82
+ if url.scheme not in self.scheme_transports:
71
83
  raise ValueError(f"No transport class found for `{url.scheme}`")
72
- if isinstance(transport_class, str):
73
- transport_class = import_string(transport_class)
74
- self.transport = transport_class(url, self.environment)
84
+ resolved_class = import_string(self.scheme_transports[url.scheme])
85
+ elif isinstance(transport_class, str):
86
+ resolved_class = import_string(transport_class)
87
+ else:
88
+ resolved_class = transport_class
89
+ if not issubclass(resolved_class, BaseTransport):
90
+ raise ValueError(
91
+ f"Transport class `{transport_class}` must be a subclass of"
92
+ "BaseTransport."
93
+ )
94
+ self.transport = resolved_class(url, self.environment, self.node)
75
95
  self.request_attr = request_attr
76
96
  self.logger_name = logger_name
77
97
  self.tags = tags or {}
78
98
  self.include_headers = include_headers
79
- self.exclude_headers = exclude_headers
99
+ self.exclude_headers = (
100
+ self.sensitive_headers if exclude_headers is None else set(exclude_headers)
101
+ )
102
+ self.include_settings = include_settings
103
+ self.exclude_settings = (
104
+ self.sensitive_settings
105
+ if exclude_settings is None
106
+ else set(exclude_settings)
107
+ )
108
+ self.include_default_settings = include_default_settings
109
+ self.include_env = include_env
110
+ self.exclude_env = (
111
+ self.sensitive_env if exclude_env is None else set(exclude_env)
112
+ )
80
113
  if log_queries or query_metrics:
81
114
  try:
82
115
  # The logger is installed as early as possible, and for all connections.
@@ -86,6 +119,7 @@ class VaranusClient:
86
119
  self.query_logger = QueryLogger(
87
120
  log_queries,
88
121
  log_query_params,
122
+ log_query_stack,
89
123
  query_metrics,
90
124
  )
91
125
  # Install it in each new connection (if it's not already installed).
@@ -98,10 +132,6 @@ class VaranusClient:
98
132
  self.send_all = send_all
99
133
  self.configured = True
100
134
  if install:
101
- if not isinstance(install, list):
102
- raise TypeError(
103
- "The varanus middleware can only be automatically installed into a list."
104
- )
105
135
  if "django.contrib.auth.middleware.AuthenticationMiddleware" in install:
106
136
  idx = install.index(
107
137
  "django.contrib.auth.middleware.AuthenticationMiddleware"
@@ -119,16 +149,39 @@ class VaranusClient:
119
149
  self.transport.send(e)
120
150
 
121
151
  def ping(self):
152
+ from django import get_version
153
+ from django.conf import settings
154
+
155
+ include_settings = resolve_include_exclude(
156
+ [s for s in dir(settings) if s == s.upper()],
157
+ self.include_settings,
158
+ self.exclude_settings,
159
+ )
160
+ include_env = resolve_include_exclude(
161
+ os.environ.keys(),
162
+ self.include_env,
163
+ self.exclude_env,
164
+ )
165
+
122
166
  self.transport.ping(
123
167
  NodeInfo(
124
- name=platform.node(),
168
+ name=self.node,
125
169
  platform=platform.platform(),
126
- python_version=platform.python_version(),
170
+ language=platform.python_implementation(),
171
+ language_version=platform.python_version(),
172
+ framework="Django",
173
+ framework_version=get_version(),
127
174
  packages={d.name: d.version for d in distributions()},
175
+ settings={
176
+ s: repr(getattr(settings, s))
177
+ for s in include_settings
178
+ if self.include_default_settings or settings.is_overridden(s)
179
+ },
180
+ environment={e: os.environ[e] for e in include_env},
128
181
  )
129
182
  )
130
183
 
131
- def log(self, level, message, *args, **kwargs):
184
+ def log(self, level: int, message: str, *args, **kwargs):
132
185
  if ctx := current_context.get():
133
186
  kwargs.setdefault("stacklevel", 2)
134
187
  ctx.log(level, message, *args, **kwargs)
@@ -137,7 +190,7 @@ class VaranusClient:
137
190
  if ctx := current_context.get():
138
191
  ctx.raw_exception(exception, tags=tags)
139
192
 
140
- def metric(self, name, value: float = 0.0, tags: dict | None = None):
193
+ def metric(self, name: str, value: float = 0.0, tags: dict | None = None):
141
194
  if ctx := current_context.get():
142
195
  ctx.metric(name, value, tags=tags)
143
196
 
varanus/client/context.py CHANGED
@@ -78,11 +78,11 @@ class VaranusContext:
78
78
 
79
79
  def log(
80
80
  self,
81
- level,
82
- message,
81
+ level: int,
82
+ message: str,
83
83
  *args,
84
84
  exc_info=None,
85
- stacklevel=1,
85
+ stacklevel: int = 1,
86
86
  tags: dict | None = None,
87
87
  **kwargs,
88
88
  ):
@@ -99,27 +99,27 @@ class VaranusContext:
99
99
  )
100
100
  )
101
101
 
102
- def debug(self, message, *args, **kwargs):
102
+ def debug(self, message: str, *args, **kwargs):
103
103
  kwargs.setdefault("stacklevel", 2)
104
104
  self.log(logging.DEBUG, message, *args, **kwargs)
105
105
 
106
- def info(self, message, *args, **kwargs):
106
+ def info(self, message: str, *args, **kwargs):
107
107
  kwargs.setdefault("stacklevel", 2)
108
108
  self.log(logging.INFO, message, *args, **kwargs)
109
109
 
110
- def warning(self, message, *args, **kwargs):
110
+ def warning(self, message: str, *args, **kwargs):
111
111
  kwargs.setdefault("stacklevel", 2)
112
112
  self.log(logging.WARNING, message, *args, **kwargs)
113
113
 
114
- def error(self, message, *args, **kwargs):
114
+ def error(self, message: str, *args, **kwargs):
115
115
  kwargs.setdefault("stacklevel", 2)
116
116
  self.log(logging.ERROR, message, *args, **kwargs)
117
117
 
118
- def critical(self, message, *args, **kwargs):
118
+ def critical(self, message: str, *args, **kwargs):
119
119
  kwargs.setdefault("stacklevel", 2)
120
120
  self.log(logging.CRITICAL, message, *args, **kwargs)
121
121
 
122
- def exception(self, message, *args, **kwargs):
122
+ def exception(self, message: str, *args, **kwargs):
123
123
  kwargs.setdefault("stacklevel", 2)
124
124
  kwargs.setdefault("exc_info", sys.exc_info())
125
125
  self.log(logging.ERROR, message, *args, **kwargs)
@@ -128,12 +128,12 @@ class VaranusContext:
128
128
  if err := Error.from_exception(exception, tags=tags):
129
129
  self.errors.append(err)
130
130
 
131
- def context(self, name="", tags: dict | None = None):
131
+ def context(self, name: str = "", tags: dict | None = None):
132
132
  ctx = VaranusContext(self.client, name, tags)
133
133
  self.subcontexts.append(ctx)
134
134
  return ctx
135
135
 
136
- def metric(self, name, value: float = 0.0, tags: dict | None = None):
136
+ def metric(self, name: str, value: float = 0.0, tags: dict | None = None):
137
137
  if name not in self.metrics:
138
138
  self.metrics[name] = Metric(name=name, tags=tags or {})
139
139
  self.metrics[name].update(value)
varanus/client/loggers.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  import sys
3
3
 
4
- from ..events import Log, Query, now
4
+ from ..events import Log, Query, capture_stack, now
5
5
  from .context import ONE_MS, current_context
6
6
 
7
7
 
@@ -12,7 +12,7 @@ class VaranusHandler(logging.Handler):
12
12
 
13
13
 
14
14
  class QueryLogger:
15
- def __init__(self, threshold, log_params, metrics):
15
+ def __init__(self, threshold, log_params, log_stack, metrics):
16
16
  # TODO: add callback for tagging?
17
17
  if threshold is True:
18
18
  self.threshold = 0
@@ -21,6 +21,7 @@ class QueryLogger:
21
21
  else:
22
22
  self.threshold = int(threshold)
23
23
  self.log_params = log_params
24
+ self.log_stack = log_stack
24
25
  if isinstance(metrics, str):
25
26
  self.metrics_name = metrics
26
27
  else:
@@ -55,5 +56,6 @@ class QueryLogger:
55
56
  db=context["connection"].alias,
56
57
  elapsed_ms=elapsed_ms,
57
58
  success=success,
59
+ stack=capture_stack(1) if self.log_stack else [],
58
60
  )
59
61
  )
@@ -1,12 +1,10 @@
1
- import re
1
+ import ipaddress
2
2
 
3
3
  from django.core.exceptions import MiddlewareNotUsed
4
4
  from django.http import HttpRequest, HttpResponse
5
5
 
6
6
  from ..events import Request
7
- from .client import client
8
-
9
- IP_REGEX = re.compile(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}")
7
+ from .client import client, resolve_include_exclude
10
8
 
11
9
 
12
10
  def get_ip(request: HttpRequest):
@@ -15,24 +13,21 @@ def get_ip(request: HttpRequest):
15
13
  ip_address = ip_address.split(",")[0].strip()
16
14
  if not ip_address:
17
15
  ip_address = request.META.get("REMOTE_ADDR", "127.0.0.1").strip()
18
- if not IP_REGEX.match(ip_address):
19
- ip_address = None
20
- return ip_address
16
+ try:
17
+ # Validate and normalize the IP address.
18
+ return str(ipaddress.ip_address(ip_address))
19
+ except ValueError:
20
+ return ""
21
21
 
22
22
 
23
23
  def request_headers(request: HttpRequest):
24
24
  headers = {}
25
- if not client.include_headers:
26
- return headers
27
- if client.include_headers is True:
28
- include = set(name.lower() for name in request.headers)
29
- else:
30
- include = set(name.lower() for name in client.include_headers)
31
- if client.exclude_headers is None:
32
- exclude = client.sensitive_headers
33
- else:
34
- exclude = set(name.lower() for name in client.exclude_headers)
35
- for name in sorted(include - exclude):
25
+ include_headers = resolve_include_exclude(
26
+ [name.lower() for name in request.headers],
27
+ client.include_headers,
28
+ client.exclude_headers,
29
+ )
30
+ for name in include_headers:
36
31
  value = request.headers.get(name)
37
32
  if value is not None:
38
33
  headers[name] = value
@@ -59,8 +54,9 @@ class VaranusMiddleware:
59
54
  # TODO: any need for request tags separate from context tags?
60
55
  varanus.request = Request(
61
56
  host=request.get_host(),
62
- path=request.path,
63
57
  method=request.method or "",
58
+ path=request.path,
59
+ query=request.META.get("QUERY_STRING", ""),
64
60
  status=response.status_code,
65
61
  headers=request_headers(request),
66
62
  size=(
@@ -4,7 +4,7 @@ from varanus import events
4
4
 
5
5
 
6
6
  class BaseTransport:
7
- def __init__(self, url: SplitResult, environment: str):
7
+ def __init__(self, url: SplitResult, environment: str, node: str):
8
8
  pass
9
9
 
10
10
  def ping(self, info: events.NodeInfo):
@@ -1,28 +1,55 @@
1
+ import typing
1
2
  import uuid
2
3
  from urllib.parse import SplitResult
3
4
 
4
- from django.db import transaction
5
-
6
5
  from varanus import events
7
- from varanus.server import models
8
6
 
9
7
  from .base import BaseTransport
10
8
 
9
+ if typing.TYPE_CHECKING:
10
+ from varanus.server.models import Node, Site
11
+
11
12
 
12
13
  class ModelTransport(BaseTransport):
13
- def __init__(self, url: SplitResult, environment: str):
14
+ site: "Site"
15
+ node: "Node"
16
+
17
+ def __init__(self, url: SplitResult, environment: str, node: str):
18
+ self.slug = url.netloc
14
19
  self.environment = environment
15
20
 
21
+ def ensure_site(self):
22
+ if hasattr(self, "site"):
23
+ return
24
+
25
+ from varanus.server.models import Site
26
+
27
+ self.site, created = Site.objects.get_or_create(
28
+ slug=self.slug,
29
+ defaults={
30
+ "name": self.slug,
31
+ "schema_name": self.slug,
32
+ },
33
+ )
34
+
16
35
  def ping(self, info: events.NodeInfo):
17
- site = models.Site.objects.get()
18
- models.Node.update(info, site=site, environment=self.environment)
36
+ from varanus.server.models import Node
37
+
38
+ self.ensure_site()
39
+
40
+ with self.site.activated():
41
+ self.node, created, updates = Node.update(
42
+ info, site=self.site, environment=self.environment
43
+ )
19
44
 
20
- @transaction.atomic
21
45
  def send(self, event: events.Context):
22
- site = models.Site.objects.get()
23
- models.Context.from_event(
24
- event,
25
- event_id=uuid.uuid4(),
26
- site=site,
27
- environment=self.environment,
28
- )
46
+ from varanus.server.models import Context
47
+
48
+ with self.site.activated():
49
+ Context.from_event(
50
+ event,
51
+ event_id=uuid.uuid4(),
52
+ site=self.site,
53
+ environment=self.environment,
54
+ node=self.node,
55
+ )
@@ -1,6 +1,9 @@
1
- import platform
1
+ import logging
2
+ import queue
3
+ import threading
4
+ import time
2
5
  from typing import Any
3
- from urllib.parse import SplitResult
6
+ from urllib.parse import SplitResult, parse_qs
4
7
 
5
8
  import httpx
6
9
  import msgspec
@@ -9,29 +12,72 @@ from varanus import events
9
12
 
10
13
  from .base import BaseTransport
11
14
 
15
+ logger = logging.getLogger(__name__)
16
+
12
17
 
13
18
  class HttpTransport(BaseTransport):
14
- def __init__(self, url: SplitResult, environment: str):
19
+ def __init__(self, url: SplitResult, environment: str, node: str):
15
20
  path = url.path.rstrip("/")
16
21
  self.ping_url = f"{url.scheme}://{url.netloc}{path}/api/ping/"
17
22
  self.event_url = f"{url.scheme}://{url.netloc}{path}/api/ingest/"
23
+ self.options = parse_qs(url.query, keep_blank_values=True)
24
+ timeout = 1.0
25
+ if "timeout" in self.options:
26
+ timeout = float(self.options["timeout"][0])
18
27
  self.client = httpx.Client(
19
28
  headers={
20
29
  "X-Varanus-Key": url.username or "",
21
- "X-Varanus-Environment": environment or "",
22
- "X-Varanus-Node": platform.node(),
30
+ "X-Varanus-Environment": environment,
31
+ "X-Varanus-Node": node,
23
32
  },
24
- timeout=1.0,
33
+ timeout=timeout,
25
34
  )
26
35
 
27
36
  def request(self, url: str, obj: Any):
28
37
  try:
29
38
  self.client.post(url, content=msgspec.json.encode(obj))
30
- except Exception as ex:
31
- print(f"error sending to {url}: {ex}")
39
+ except Exception:
40
+ logger.exception("error sending to %s", url)
32
41
 
33
42
  def ping(self, info: events.NodeInfo):
34
43
  self.request(self.ping_url, info)
35
44
 
36
45
  def send(self, event: events.Context):
37
- self.request(self.event_url, event)
46
+ self.request(self.event_url, [event])
47
+
48
+
49
+ def sender(pending: queue.SimpleQueue, client: httpx.Client, url: str, rate: float):
50
+ while True:
51
+ start = time.time()
52
+ events = []
53
+ while True:
54
+ try:
55
+ events.append(pending.get_nowait())
56
+ except queue.Empty:
57
+ break
58
+ if len(events) >= 100:
59
+ break
60
+ if events:
61
+ try:
62
+ client.post(url, content=msgspec.json.encode(events))
63
+ except Exception:
64
+ logger.exception("error sending to %s", url)
65
+ elapsed = time.time() - start
66
+ time.sleep(max(rate - elapsed, 1.0))
67
+
68
+
69
+ class ThreadedHttpTransport(HttpTransport):
70
+ def __init__(self, url: SplitResult, environment: str, node: str):
71
+ super().__init__(url, environment, node)
72
+ self.pending = queue.SimpleQueue()
73
+ rate = 3.0
74
+ if "rate" in self.options:
75
+ rate = float(self.options["rate"][0])
76
+ threading.Thread(
77
+ target=sender,
78
+ args=(self.pending, self.client, self.event_url, rate),
79
+ daemon=True,
80
+ ).start()
81
+
82
+ def send(self, event: events.Context):
83
+ self.pending.put(event)
varanus/events.py CHANGED
@@ -17,11 +17,16 @@ class Event(Struct, kw_only=True, omit_defaults=True):
17
17
  class NodeInfo(Struct):
18
18
  name: str
19
19
  platform: str
20
- python_version: str
20
+ language: str
21
+ language_version: str
22
+ framework: str
23
+ framework_version: str
21
24
  packages: dict[str, str]
25
+ settings: dict[str, str]
26
+ environment: dict[str, str]
22
27
 
23
28
 
24
- class ErrorLine(Struct):
29
+ class StackLine(Struct):
25
30
  file: str | None
26
31
  lineno: int | None
27
32
  function: str | None
@@ -30,11 +35,31 @@ class ErrorLine(Struct):
30
35
  locals: dict[str, str]
31
36
 
32
37
 
38
+ def capture_stack(skip: int = 0, include_locals: bool = False) -> list[StackLine]:
39
+ lines = []
40
+ for frame in inspect.stack()[skip + 1 :]:
41
+ lines.append(
42
+ StackLine(
43
+ file=frame.filename,
44
+ lineno=frame.lineno,
45
+ linesrc=frame.code_context[0].strip() if frame.code_context else "",
46
+ function=frame.function,
47
+ module=frame.frame.f_globals.get("__name__", ""),
48
+ locals=(
49
+ {name: repr(val) for name, val in frame.frame.f_locals.items()}
50
+ if include_locals
51
+ else {}
52
+ ),
53
+ )
54
+ )
55
+ return lines
56
+
57
+
33
58
  class Error(Event):
34
59
  kind: str
35
60
  module: str
36
61
  message: str
37
- lines: list[ErrorLine] = []
62
+ stack: list[StackLine] = []
38
63
 
39
64
  @classmethod
40
65
  def from_exception(cls, exc_info, tags=None):
@@ -63,7 +88,7 @@ class Error(Event):
63
88
  pass
64
89
  module = f_globals.get("__name__", "")
65
90
  lines.append(
66
- ErrorLine(
91
+ StackLine(
67
92
  file=abs_path,
68
93
  lineno=lineno,
69
94
  function=function,
@@ -78,7 +103,7 @@ class Error(Event):
78
103
  kind=kind,
79
104
  module=module,
80
105
  message=message,
81
- lines=lines,
106
+ stack=lines,
82
107
  )
83
108
 
84
109
 
@@ -144,12 +169,14 @@ class Query(Event):
144
169
  db: str
145
170
  elapsed_ms: int
146
171
  success: bool
172
+ stack: list[StackLine] = []
147
173
 
148
174
 
149
175
  class Request(Event):
150
176
  host: str
151
- path: str
152
177
  method: str
178
+ path: str
179
+ query: str
153
180
  status: int
154
181
  headers: dict = {}
155
182
  size: int | None = None
@@ -1,9 +1,10 @@
1
1
  from .base import Search
2
- from .date import DateRange
3
- from .facet import MultiFacet
2
+ from .fields import DateRange, Filter, Hidden, MultiFacet
4
3
 
5
4
  __all__ = [
6
5
  "Search",
7
6
  "DateRange",
7
+ "Filter",
8
+ "Hidden",
8
9
  "MultiFacet",
9
10
  ]