closeyourit 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.
- closeyourit/__init__.py +61 -0
- closeyourit/_version.py +1 -0
- closeyourit/asgi.py +73 -0
- closeyourit/client.py +240 -0
- closeyourit/configuration.py +181 -0
- closeyourit/django.py +226 -0
- closeyourit/events.py +340 -0
- closeyourit/flask.py +66 -0
- closeyourit/hooks.py +276 -0
- closeyourit/integrations/__init__.py +24 -0
- closeyourit/integrations/_http.py +204 -0
- closeyourit/integrations/celery.py +433 -0
- closeyourit/integrations/httpx.py +158 -0
- closeyourit/integrations/requests.py +112 -0
- closeyourit/integrations/sqlalchemy.py +186 -0
- closeyourit/models.py +66 -0
- closeyourit/py.typed +1 -0
- closeyourit/scope.py +191 -0
- closeyourit/scrubber.py +157 -0
- closeyourit/sql.py +35 -0
- closeyourit/transport.py +435 -0
- closeyourit/web.py +203 -0
- closeyourit/wsgi.py +134 -0
- closeyourit-0.1.0.dist-info/METADATA +413 -0
- closeyourit-0.1.0.dist-info/RECORD +26 -0
- closeyourit-0.1.0.dist-info/WHEEL +4 -0
closeyourit/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Public package for the CloseYourIt Python SDK."""
|
|
2
|
+
|
|
3
|
+
from closeyourit._version import __version__
|
|
4
|
+
from closeyourit.asgi import ASGIMiddleware
|
|
5
|
+
from closeyourit.client import Client, EventSink
|
|
6
|
+
from closeyourit.configuration import Configuration
|
|
7
|
+
from closeyourit.django import DjangoMiddleware
|
|
8
|
+
from closeyourit.events import EventBuilder
|
|
9
|
+
from closeyourit.flask import FlaskIntegration
|
|
10
|
+
from closeyourit.hooks import CloseYourItHandler, ErrorHooks
|
|
11
|
+
from closeyourit.integrations import (
|
|
12
|
+
CeleryIntegration,
|
|
13
|
+
HttpTarget,
|
|
14
|
+
HttpxInstrumentation,
|
|
15
|
+
RequestsInstrumentation,
|
|
16
|
+
SQLAlchemyInstrumentation,
|
|
17
|
+
instrument_httpx,
|
|
18
|
+
instrument_requests,
|
|
19
|
+
instrument_sqlalchemy,
|
|
20
|
+
normalize_http_target,
|
|
21
|
+
)
|
|
22
|
+
from closeyourit.scope import Scope, ScopeSnapshot, current_scope, reset_scope, with_scope
|
|
23
|
+
from closeyourit.scrubber import FILTERED, Scrubber
|
|
24
|
+
from closeyourit.sql import sql_fingerprint
|
|
25
|
+
from closeyourit.transport import HttpResponse, Transport, TransportStats, UrllibHttpRequester
|
|
26
|
+
from closeyourit.wsgi import WSGIMiddleware
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"FILTERED",
|
|
30
|
+
"ASGIMiddleware",
|
|
31
|
+
"CeleryIntegration",
|
|
32
|
+
"Client",
|
|
33
|
+
"CloseYourItHandler",
|
|
34
|
+
"Configuration",
|
|
35
|
+
"DjangoMiddleware",
|
|
36
|
+
"ErrorHooks",
|
|
37
|
+
"EventBuilder",
|
|
38
|
+
"EventSink",
|
|
39
|
+
"FlaskIntegration",
|
|
40
|
+
"HttpResponse",
|
|
41
|
+
"HttpTarget",
|
|
42
|
+
"HttpxInstrumentation",
|
|
43
|
+
"RequestsInstrumentation",
|
|
44
|
+
"SQLAlchemyInstrumentation",
|
|
45
|
+
"Scope",
|
|
46
|
+
"ScopeSnapshot",
|
|
47
|
+
"Scrubber",
|
|
48
|
+
"Transport",
|
|
49
|
+
"TransportStats",
|
|
50
|
+
"UrllibHttpRequester",
|
|
51
|
+
"WSGIMiddleware",
|
|
52
|
+
"__version__",
|
|
53
|
+
"current_scope",
|
|
54
|
+
"instrument_httpx",
|
|
55
|
+
"instrument_requests",
|
|
56
|
+
"instrument_sqlalchemy",
|
|
57
|
+
"normalize_http_target",
|
|
58
|
+
"reset_scope",
|
|
59
|
+
"sql_fingerprint",
|
|
60
|
+
"with_scope",
|
|
61
|
+
]
|
closeyourit/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
closeyourit/asgi.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Dependency-free ASGI 3 middleware."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
from closeyourit.client import Client
|
|
9
|
+
from closeyourit.scope import with_scope
|
|
10
|
+
from closeyourit.web import Monotonic, RequestLifecycle, asgi_request, route_from_asgi
|
|
11
|
+
|
|
12
|
+
Message = dict[str, object]
|
|
13
|
+
ASGIApp = Callable[..., Awaitable[None]]
|
|
14
|
+
Receive = Callable[[], Awaitable[Message]]
|
|
15
|
+
Send = Callable[[Message], Awaitable[None]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ASGIMiddleware:
|
|
19
|
+
"""Capture one HTTP connection scope while forwarding receive/send unchanged."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
app: ASGIApp,
|
|
24
|
+
client: Client,
|
|
25
|
+
*,
|
|
26
|
+
monotonic: Monotonic = time.monotonic,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.app = app
|
|
29
|
+
self.client = client
|
|
30
|
+
self.monotonic = monotonic
|
|
31
|
+
|
|
32
|
+
async def __call__(
|
|
33
|
+
self,
|
|
34
|
+
scope: dict[str, object],
|
|
35
|
+
receive: Receive,
|
|
36
|
+
send: Send,
|
|
37
|
+
) -> None:
|
|
38
|
+
if scope.get("type") != "http":
|
|
39
|
+
await self.app(scope, receive, send)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
request, trace_id = asgi_request(scope, self.client.configuration.request_header_allowlist)
|
|
43
|
+
lifecycle = RequestLifecycle(
|
|
44
|
+
self.client,
|
|
45
|
+
request,
|
|
46
|
+
trace_id,
|
|
47
|
+
route_resolver=lambda: route_from_asgi(scope),
|
|
48
|
+
monotonic=self.monotonic,
|
|
49
|
+
)
|
|
50
|
+
send_error: Exception | None = None
|
|
51
|
+
|
|
52
|
+
async def observed_send(message: Message) -> None:
|
|
53
|
+
nonlocal send_error
|
|
54
|
+
if message.get("type") == "http.response.start":
|
|
55
|
+
lifecycle.set_status(message.get("status"))
|
|
56
|
+
try:
|
|
57
|
+
await send(message)
|
|
58
|
+
except Exception as error:
|
|
59
|
+
send_error = error
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
with with_scope():
|
|
63
|
+
lifecycle.begin()
|
|
64
|
+
try:
|
|
65
|
+
await self.app(scope, receive, observed_send)
|
|
66
|
+
except Exception as error:
|
|
67
|
+
if error is send_error:
|
|
68
|
+
lifecycle.finish()
|
|
69
|
+
else:
|
|
70
|
+
lifecycle.finish(error)
|
|
71
|
+
raise
|
|
72
|
+
else:
|
|
73
|
+
lifecycle.finish()
|
closeyourit/client.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Public capture facade independent from the concrete asynchronous transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from typing import Literal, Protocol, TypeVar, runtime_checkable
|
|
10
|
+
|
|
11
|
+
from closeyourit.configuration import Configuration
|
|
12
|
+
from closeyourit.events import EventBuilder
|
|
13
|
+
from closeyourit.models import EventPayload
|
|
14
|
+
from closeyourit.scope import current_scope
|
|
15
|
+
|
|
16
|
+
Channel = Literal["events", "logs", "metrics"]
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EventSink(Protocol):
|
|
21
|
+
def enqueue(self, channel: Channel, payload: EventPayload) -> bool: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class FlushableSink(Protocol):
|
|
26
|
+
def flush(self, timeout: float | None = None) -> bool: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class CloseableSink(Protocol):
|
|
31
|
+
def close(self, timeout: float | None = None) -> bool: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NullSink:
|
|
35
|
+
def enqueue(self, channel: Channel, payload: EventPayload) -> bool:
|
|
36
|
+
del channel, payload
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Client:
|
|
41
|
+
"""Build, scrub and hand payloads to a replaceable non-blocking sink."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
configuration: Configuration | None = None,
|
|
46
|
+
*,
|
|
47
|
+
sink: EventSink | None = None,
|
|
48
|
+
random_value: Callable[[], float] = random.random,
|
|
49
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.configuration = configuration or Configuration()
|
|
52
|
+
if sink is None and self.configuration.enabled:
|
|
53
|
+
from closeyourit.transport import Transport
|
|
54
|
+
|
|
55
|
+
sink = Transport(self.configuration)
|
|
56
|
+
self.sink = sink or NullSink()
|
|
57
|
+
self.random_value = random_value
|
|
58
|
+
self.monotonic = monotonic
|
|
59
|
+
self.builder = EventBuilder(self.configuration)
|
|
60
|
+
|
|
61
|
+
def capture_exception(
|
|
62
|
+
self,
|
|
63
|
+
error: BaseException,
|
|
64
|
+
*,
|
|
65
|
+
handled: bool = False,
|
|
66
|
+
level: str = "error",
|
|
67
|
+
contexts: Mapping[str, object] | None = None,
|
|
68
|
+
) -> str | None:
|
|
69
|
+
if not self._sampled(self.configuration.sample_rate):
|
|
70
|
+
return None
|
|
71
|
+
return self._submit(
|
|
72
|
+
"events",
|
|
73
|
+
self.builder.exception(error, handled=handled, level=level, contexts=contexts),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def capture_message(self, message: object, *, level: str = "info") -> str | None:
|
|
77
|
+
if not self._sampled(self.configuration.sample_rate):
|
|
78
|
+
return None
|
|
79
|
+
return self._submit("events", self.builder.message(message, level=level))
|
|
80
|
+
|
|
81
|
+
def log(
|
|
82
|
+
self,
|
|
83
|
+
message: object,
|
|
84
|
+
*,
|
|
85
|
+
level: str = "info",
|
|
86
|
+
logger: str | None = None,
|
|
87
|
+
**attributes: object,
|
|
88
|
+
) -> str | None:
|
|
89
|
+
if not self._sampled(self.configuration.logs_sample_rate):
|
|
90
|
+
return None
|
|
91
|
+
return self._submit(
|
|
92
|
+
"logs",
|
|
93
|
+
self.builder.log(message, level=level, logger=logger, attributes=attributes),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def add_breadcrumb(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
message: str | None = None,
|
|
100
|
+
category: str | None = None,
|
|
101
|
+
type: str = "default",
|
|
102
|
+
level: str = "info",
|
|
103
|
+
data: Mapping[str, object] | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
if not self.configuration.breadcrumbs_enabled:
|
|
106
|
+
return
|
|
107
|
+
breadcrumb: EventPayload = {
|
|
108
|
+
"timestamp": self.builder.timestamp(),
|
|
109
|
+
"type": type,
|
|
110
|
+
"level": level,
|
|
111
|
+
}
|
|
112
|
+
if message is not None:
|
|
113
|
+
breadcrumb["message"] = message
|
|
114
|
+
if category is not None:
|
|
115
|
+
breadcrumb["category"] = category
|
|
116
|
+
if data:
|
|
117
|
+
breadcrumb["data"] = dict(data)
|
|
118
|
+
current_scope().add_breadcrumb(
|
|
119
|
+
breadcrumb, max_breadcrumbs=self.configuration.max_breadcrumbs
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def measure(self, label: str, operation: Callable[[], T]) -> T:
|
|
123
|
+
label = label.strip()
|
|
124
|
+
if not label:
|
|
125
|
+
raise ValueError("measurement label must not be blank")
|
|
126
|
+
started_at = self.monotonic()
|
|
127
|
+
try:
|
|
128
|
+
return operation()
|
|
129
|
+
finally:
|
|
130
|
+
duration_ms = (self.monotonic() - started_at) * 1000.0
|
|
131
|
+
if duration_ms >= self.configuration.slow_method_threshold_ms:
|
|
132
|
+
self.capture_duration(label, duration_ms)
|
|
133
|
+
|
|
134
|
+
def capture_duration(
|
|
135
|
+
self,
|
|
136
|
+
label: str,
|
|
137
|
+
duration_ms: float,
|
|
138
|
+
*,
|
|
139
|
+
contexts: Mapping[str, object] | None = None,
|
|
140
|
+
) -> str | None:
|
|
141
|
+
"""Capture a finite duration enriched with release and scrubbed integration context."""
|
|
142
|
+
try:
|
|
143
|
+
normalized_duration = float(duration_ms)
|
|
144
|
+
except (TypeError, ValueError, OverflowError):
|
|
145
|
+
return None
|
|
146
|
+
if not math.isfinite(normalized_duration):
|
|
147
|
+
return None
|
|
148
|
+
payload = self.builder.slow_method(label, max(0.0, normalized_duration))
|
|
149
|
+
if self.configuration.release is not None:
|
|
150
|
+
payload["release"] = self.configuration.release
|
|
151
|
+
if contexts:
|
|
152
|
+
payload["contexts"] = self.builder.scrubber.scrub(dict(contexts))
|
|
153
|
+
return self._submit("metrics", payload)
|
|
154
|
+
|
|
155
|
+
def capture_slow_request(
|
|
156
|
+
self,
|
|
157
|
+
*,
|
|
158
|
+
route: str,
|
|
159
|
+
path: str,
|
|
160
|
+
url: str,
|
|
161
|
+
duration_ms: float,
|
|
162
|
+
) -> str | None:
|
|
163
|
+
return self._submit(
|
|
164
|
+
"metrics",
|
|
165
|
+
self.builder.slow_request(
|
|
166
|
+
route=route,
|
|
167
|
+
path=path,
|
|
168
|
+
url=url,
|
|
169
|
+
duration_ms=duration_ms,
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def capture_slow_query(
|
|
174
|
+
self,
|
|
175
|
+
sql: str,
|
|
176
|
+
duration_ms: float,
|
|
177
|
+
*,
|
|
178
|
+
db_system: str | None = None,
|
|
179
|
+
source: str | None = None,
|
|
180
|
+
) -> str | None:
|
|
181
|
+
return self._submit(
|
|
182
|
+
"metrics",
|
|
183
|
+
self.builder.slow_query(sql, duration_ms, db_system=db_system, source=source),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def capture_performance_issue(
|
|
187
|
+
self,
|
|
188
|
+
subtype: str,
|
|
189
|
+
*,
|
|
190
|
+
duration_ms: float | None = None,
|
|
191
|
+
sql: str | None = None,
|
|
192
|
+
source: str | None = None,
|
|
193
|
+
route: str | None = None,
|
|
194
|
+
http_host: str | None = None,
|
|
195
|
+
http_url: str | None = None,
|
|
196
|
+
query_count: int | None = None,
|
|
197
|
+
total_query_time_ms: float | None = None,
|
|
198
|
+
) -> str | None:
|
|
199
|
+
return self._submit(
|
|
200
|
+
"metrics",
|
|
201
|
+
self.builder.performance_issue(
|
|
202
|
+
subtype,
|
|
203
|
+
duration_ms=duration_ms,
|
|
204
|
+
sql=sql,
|
|
205
|
+
source=source,
|
|
206
|
+
route=route,
|
|
207
|
+
http_host=http_host,
|
|
208
|
+
http_url=http_url,
|
|
209
|
+
query_count=query_count,
|
|
210
|
+
total_query_time_ms=total_query_time_ms,
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def flush(self, timeout: float | None = None) -> bool:
|
|
215
|
+
"""Wait for an asynchronous sink, or succeed immediately for a synchronous sink."""
|
|
216
|
+
if isinstance(self.sink, FlushableSink):
|
|
217
|
+
return self.sink.flush(timeout)
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
def close(self, timeout: float | None = None) -> bool:
|
|
221
|
+
"""Close a managed sink idempotently."""
|
|
222
|
+
if isinstance(self.sink, CloseableSink):
|
|
223
|
+
return self.sink.close(timeout)
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
def _sampled(self, rate: float) -> bool:
|
|
227
|
+
return (
|
|
228
|
+
self.configuration.enabled
|
|
229
|
+
and rate > 0.0
|
|
230
|
+
and (rate >= 1.0 or self.random_value() < rate)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _submit(self, channel: Channel, payload: EventPayload) -> str | None:
|
|
234
|
+
if not self.configuration.enabled:
|
|
235
|
+
return None
|
|
236
|
+
prepared = self.configuration.apply_before_send(payload)
|
|
237
|
+
if prepared is None or not self.sink.enqueue(channel, prepared):
|
|
238
|
+
return None
|
|
239
|
+
identifier = prepared.get("event_id", prepared.get("sample_id"))
|
|
240
|
+
return str(identifier) if identifier is not None else None
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Immutable, fail-safe configuration for the CloseYourIt client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Literal, TypeAlias
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
from closeyourit.models import BeforeSend, EventPayload, FilterParameter
|
|
13
|
+
|
|
14
|
+
DisabledReason: TypeAlias = Literal[
|
|
15
|
+
"missing_endpoint_url",
|
|
16
|
+
"missing_token",
|
|
17
|
+
"missing_project_id",
|
|
18
|
+
"invalid_endpoint_url",
|
|
19
|
+
"insecure_endpoint_in_production",
|
|
20
|
+
"invalid_token",
|
|
21
|
+
"invalid_project_id",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _optional_environment(name: str) -> str | None:
|
|
26
|
+
return os.environ.get(name)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _environment() -> str:
|
|
30
|
+
return os.environ.get("CLOSEYOURIT_ENVIRONMENT", "development")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _clean_optional(value: str | None) -> str | None:
|
|
34
|
+
if value is None:
|
|
35
|
+
return None
|
|
36
|
+
cleaned = value.strip()
|
|
37
|
+
return cleaned or None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class Configuration:
|
|
42
|
+
"""Validated settings; invalid or incomplete values make the SDK a no-op."""
|
|
43
|
+
|
|
44
|
+
endpoint_url: str | None = field(
|
|
45
|
+
default_factory=lambda: _optional_environment("CLOSEYOURIT_ENDPOINT_URL")
|
|
46
|
+
)
|
|
47
|
+
token: str | None = field(default_factory=lambda: _optional_environment("CLOSEYOURIT_TOKEN"))
|
|
48
|
+
project_id: str | None = field(
|
|
49
|
+
default_factory=lambda: _optional_environment("CLOSEYOURIT_PROJECT_ID")
|
|
50
|
+
)
|
|
51
|
+
environment: str = field(default_factory=_environment)
|
|
52
|
+
release: str | None = field(
|
|
53
|
+
default_factory=lambda: _optional_environment("CLOSEYOURIT_RELEASE")
|
|
54
|
+
)
|
|
55
|
+
send_pii: bool = False
|
|
56
|
+
filter_parameters: tuple[FilterParameter, ...] = ()
|
|
57
|
+
before_send: BeforeSend | None = None
|
|
58
|
+
sample_rate: float = 1.0
|
|
59
|
+
logs_sample_rate: float = 1.0
|
|
60
|
+
breadcrumbs_enabled: bool = True
|
|
61
|
+
max_breadcrumbs: int = 100
|
|
62
|
+
slow_method_threshold_ms: float = 500.0
|
|
63
|
+
slow_request_threshold_ms: float = 1000.0
|
|
64
|
+
request_header_allowlist: tuple[str, ...] = (
|
|
65
|
+
"Accept",
|
|
66
|
+
"Content-Type",
|
|
67
|
+
"User-Agent",
|
|
68
|
+
)
|
|
69
|
+
slow_query_threshold_ms: float = 100.0
|
|
70
|
+
slow_external_threshold_ms: float = 1_000.0
|
|
71
|
+
repeated_http_threshold: int = 5
|
|
72
|
+
repeated_http_window_ms: float = 5_000.0
|
|
73
|
+
n_plus_one_threshold: int = 10
|
|
74
|
+
query_count_threshold: int = 100
|
|
75
|
+
http_capture_hosts: tuple[str, ...] = ()
|
|
76
|
+
trace_propagation_enabled: bool = False
|
|
77
|
+
trace_propagation_hosts: tuple[str, ...] = ()
|
|
78
|
+
|
|
79
|
+
def __post_init__(self) -> None:
|
|
80
|
+
endpoint = _clean_optional(self.endpoint_url)
|
|
81
|
+
object.__setattr__(self, "endpoint_url", endpoint.rstrip("/") if endpoint else None)
|
|
82
|
+
object.__setattr__(self, "token", _clean_optional(self.token))
|
|
83
|
+
object.__setattr__(self, "project_id", _clean_optional(self.project_id))
|
|
84
|
+
environment = self.environment.strip().casefold() or "development"
|
|
85
|
+
object.__setattr__(self, "environment", environment)
|
|
86
|
+
object.__setattr__(self, "release", _clean_optional(self.release))
|
|
87
|
+
object.__setattr__(self, "filter_parameters", tuple(self.filter_parameters))
|
|
88
|
+
object.__setattr__(self, "sample_rate", min(1.0, max(0.0, float(self.sample_rate))))
|
|
89
|
+
object.__setattr__(
|
|
90
|
+
self, "logs_sample_rate", min(1.0, max(0.0, float(self.logs_sample_rate)))
|
|
91
|
+
)
|
|
92
|
+
object.__setattr__(self, "max_breadcrumbs", max(0, int(self.max_breadcrumbs)))
|
|
93
|
+
object.__setattr__(
|
|
94
|
+
self, "slow_method_threshold_ms", max(0.0, float(self.slow_method_threshold_ms))
|
|
95
|
+
)
|
|
96
|
+
object.__setattr__(
|
|
97
|
+
self, "slow_request_threshold_ms", max(0.0, float(self.slow_request_threshold_ms))
|
|
98
|
+
)
|
|
99
|
+
object.__setattr__(
|
|
100
|
+
self,
|
|
101
|
+
"request_header_allowlist",
|
|
102
|
+
tuple(
|
|
103
|
+
dict.fromkeys(
|
|
104
|
+
name.strip() for name in self.request_header_allowlist if name.strip()
|
|
105
|
+
)
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
object.__setattr__(
|
|
109
|
+
self, "slow_query_threshold_ms", max(0.0, float(self.slow_query_threshold_ms))
|
|
110
|
+
)
|
|
111
|
+
object.__setattr__(
|
|
112
|
+
self,
|
|
113
|
+
"slow_external_threshold_ms",
|
|
114
|
+
max(0.0, float(self.slow_external_threshold_ms)),
|
|
115
|
+
)
|
|
116
|
+
object.__setattr__(
|
|
117
|
+
self, "repeated_http_threshold", max(1, int(self.repeated_http_threshold))
|
|
118
|
+
)
|
|
119
|
+
object.__setattr__(
|
|
120
|
+
self, "repeated_http_window_ms", max(0.0, float(self.repeated_http_window_ms))
|
|
121
|
+
)
|
|
122
|
+
object.__setattr__(self, "n_plus_one_threshold", max(0, int(self.n_plus_one_threshold)))
|
|
123
|
+
object.__setattr__(self, "query_count_threshold", max(0, int(self.query_count_threshold)))
|
|
124
|
+
object.__setattr__(
|
|
125
|
+
self,
|
|
126
|
+
"http_capture_hosts",
|
|
127
|
+
tuple(host.strip().casefold() for host in self.http_capture_hosts if host.strip()),
|
|
128
|
+
)
|
|
129
|
+
object.__setattr__(
|
|
130
|
+
self,
|
|
131
|
+
"trace_propagation_hosts",
|
|
132
|
+
tuple(host.strip().casefold() for host in self.trace_propagation_hosts if host.strip()),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def production(self) -> bool:
|
|
137
|
+
return self.environment == "production"
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def enabled(self) -> bool:
|
|
141
|
+
return self.disabled_reason is None
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def disabled_reason(self) -> DisabledReason | None:
|
|
145
|
+
if self.endpoint_url is None:
|
|
146
|
+
return "missing_endpoint_url"
|
|
147
|
+
if self.token is None:
|
|
148
|
+
return "missing_token"
|
|
149
|
+
if self.project_id is None:
|
|
150
|
+
return "missing_project_id"
|
|
151
|
+
|
|
152
|
+
endpoint = urlsplit(self.endpoint_url)
|
|
153
|
+
if (
|
|
154
|
+
endpoint.scheme not in {"http", "https"}
|
|
155
|
+
or endpoint.hostname is None
|
|
156
|
+
or endpoint.username is not None
|
|
157
|
+
or endpoint.password is not None
|
|
158
|
+
):
|
|
159
|
+
return "invalid_endpoint_url"
|
|
160
|
+
if self.production and endpoint.scheme != "https":
|
|
161
|
+
return "insecure_endpoint_in_production"
|
|
162
|
+
if not self.token.startswith("cyi_"):
|
|
163
|
+
return "invalid_token"
|
|
164
|
+
try:
|
|
165
|
+
if str(UUID(self.project_id)) != self.project_id.casefold():
|
|
166
|
+
return "invalid_project_id"
|
|
167
|
+
except ValueError:
|
|
168
|
+
return "invalid_project_id"
|
|
169
|
+
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def apply_before_send(self, event: EventPayload) -> EventPayload | None:
|
|
173
|
+
"""Run the consumer hook on a copy; callback failures safely drop the event."""
|
|
174
|
+
try:
|
|
175
|
+
payload = deepcopy(event)
|
|
176
|
+
if self.before_send is None:
|
|
177
|
+
return payload
|
|
178
|
+
result = self.before_send(payload)
|
|
179
|
+
return deepcopy(result) if isinstance(result, dict) else None
|
|
180
|
+
except Exception:
|
|
181
|
+
return None
|