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.
@@ -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
+ ]
@@ -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