statoss 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.
- statoss/__init__.py +60 -0
- statoss/_client.py +284 -0
- statoss/_schema.py +468 -0
- statoss/py.typed +0 -0
- statoss-0.1.0.dist-info/METADATA +187 -0
- statoss-0.1.0.dist-info/RECORD +8 -0
- statoss-0.1.0.dist-info/WHEEL +4 -0
- statoss-0.1.0.dist-info/licenses/LICENSE +21 -0
statoss/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Error tracking for StatOSS: sentry-sdk with the app's own levels, categories and fields."""
|
|
2
|
+
|
|
3
|
+
from sentry_sdk import add_breadcrumb, flush, set_context, set_tag, set_user
|
|
4
|
+
|
|
5
|
+
from ._client import (
|
|
6
|
+
Client,
|
|
7
|
+
FieldValue,
|
|
8
|
+
capture_exception,
|
|
9
|
+
capture_message,
|
|
10
|
+
init,
|
|
11
|
+
set_category,
|
|
12
|
+
)
|
|
13
|
+
from ._schema import (
|
|
14
|
+
CATEGORY_TAG,
|
|
15
|
+
LEVEL_TAG,
|
|
16
|
+
Category,
|
|
17
|
+
Field,
|
|
18
|
+
FieldType,
|
|
19
|
+
Level,
|
|
20
|
+
Like,
|
|
21
|
+
Rule,
|
|
22
|
+
RuleMatch,
|
|
23
|
+
RuleSubject,
|
|
24
|
+
Schema,
|
|
25
|
+
SchemaColor,
|
|
26
|
+
SchemaError,
|
|
27
|
+
SentryLevel,
|
|
28
|
+
define_schema,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"CATEGORY_TAG",
|
|
35
|
+
"LEVEL_TAG",
|
|
36
|
+
"Category",
|
|
37
|
+
"Client",
|
|
38
|
+
"Field",
|
|
39
|
+
"FieldType",
|
|
40
|
+
"FieldValue",
|
|
41
|
+
"Level",
|
|
42
|
+
"Like",
|
|
43
|
+
"Rule",
|
|
44
|
+
"RuleMatch",
|
|
45
|
+
"RuleSubject",
|
|
46
|
+
"Schema",
|
|
47
|
+
"SchemaColor",
|
|
48
|
+
"SchemaError",
|
|
49
|
+
"SentryLevel",
|
|
50
|
+
"add_breadcrumb",
|
|
51
|
+
"capture_exception",
|
|
52
|
+
"capture_message",
|
|
53
|
+
"define_schema",
|
|
54
|
+
"flush",
|
|
55
|
+
"init",
|
|
56
|
+
"set_category",
|
|
57
|
+
"set_context",
|
|
58
|
+
"set_tag",
|
|
59
|
+
"set_user",
|
|
60
|
+
]
|
statoss/_client.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""init and the capture functions: sentry-sdk's, with StatOSS levels and categories.
|
|
2
|
+
|
|
3
|
+
On the wire an event carries the nearest Sentry level, its own level as the
|
|
4
|
+
tag statoss.level and its category as statoss.category, as @statoss/sdk
|
|
5
|
+
sends them (sdk/src/levels.ts), so any Sentry-compatible tool reads it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import (
|
|
12
|
+
Any,
|
|
13
|
+
Callable,
|
|
14
|
+
Dict,
|
|
15
|
+
Generic,
|
|
16
|
+
Mapping,
|
|
17
|
+
Optional,
|
|
18
|
+
Tuple,
|
|
19
|
+
Union,
|
|
20
|
+
overload,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
import sentry_sdk
|
|
24
|
+
|
|
25
|
+
from ._schema import (
|
|
26
|
+
BUILTIN_LEVELS,
|
|
27
|
+
CATEGORY_TAG,
|
|
28
|
+
LEVEL_TAG,
|
|
29
|
+
SENTRY_LEVELS,
|
|
30
|
+
C,
|
|
31
|
+
L,
|
|
32
|
+
Schema,
|
|
33
|
+
SentryLevel,
|
|
34
|
+
is_category_name,
|
|
35
|
+
is_level_name,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("statoss")
|
|
39
|
+
|
|
40
|
+
FieldValue = Union[str, int, float, bool, None]
|
|
41
|
+
"""A field's value; it travels as a tag, so as text."""
|
|
42
|
+
|
|
43
|
+
Error = Union[BaseException, Tuple[Any, Any, Any], None]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _debug() -> bool:
|
|
47
|
+
try:
|
|
48
|
+
return bool(sentry_sdk.get_client().options.get("debug"))
|
|
49
|
+
except Exception:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _warn(message: str, *args: object) -> None:
|
|
54
|
+
if _debug():
|
|
55
|
+
logger.warning(message, *args)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _tag_text(value: object) -> str:
|
|
59
|
+
if isinstance(value, bool):
|
|
60
|
+
return "true" if value else "false"
|
|
61
|
+
return value if isinstance(value, str) else str(value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Client(Generic[L, C]):
|
|
65
|
+
"""The capture functions, typed by a schema's level and category names.
|
|
66
|
+
|
|
67
|
+
`init` hands one back. With a schema made as
|
|
68
|
+
`Schema[Literal["payment-failed"], Literal["billing"]]`, a type checker
|
|
69
|
+
refuses `level="payment-faild"`.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, schema: Optional[Schema[L, C]] = None) -> None:
|
|
73
|
+
self.schema = schema
|
|
74
|
+
|
|
75
|
+
def _level(self, level: Optional[str]) -> Tuple[Optional[str], Dict[str, str]]:
|
|
76
|
+
"""The Sentry level to send, and the tag naming the app's own level."""
|
|
77
|
+
if level is None:
|
|
78
|
+
return None, {}
|
|
79
|
+
if not is_level_name(level) and level not in SENTRY_LEVELS:
|
|
80
|
+
_warn(
|
|
81
|
+
"Ignored level %r: use a Sentry level or 1 to 32 lowercase letters, "
|
|
82
|
+
"digits, dots, dashes or underscores.",
|
|
83
|
+
level,
|
|
84
|
+
)
|
|
85
|
+
return None, {}
|
|
86
|
+
if level in BUILTIN_LEVELS:
|
|
87
|
+
return level, {}
|
|
88
|
+
schema = self.schema
|
|
89
|
+
sent_as = schema.sentry_level(level) if schema is not None else None
|
|
90
|
+
if sent_as is not None:
|
|
91
|
+
return sent_as, {LEVEL_TAG: level}
|
|
92
|
+
if level in SENTRY_LEVELS:
|
|
93
|
+
return level, {}
|
|
94
|
+
if schema is not None:
|
|
95
|
+
_warn("Level %r is not in the schema, so it is sent as error.", level)
|
|
96
|
+
return "error", {LEVEL_TAG: level}
|
|
97
|
+
|
|
98
|
+
def _tags(
|
|
99
|
+
self,
|
|
100
|
+
level: Optional[str],
|
|
101
|
+
category: Optional[str],
|
|
102
|
+
fields: Optional[Mapping[str, FieldValue]],
|
|
103
|
+
) -> Tuple[Optional[str], Dict[str, str]]:
|
|
104
|
+
tags: Dict[str, str] = {}
|
|
105
|
+
for key, value in (fields or {}).items():
|
|
106
|
+
if not isinstance(key, str) or key == "" or key.startswith("statoss."):
|
|
107
|
+
_warn("Ignored field %r: a field is a tag key not starting statoss.", key)
|
|
108
|
+
elif value is not None:
|
|
109
|
+
tags[key] = _tag_text(value)
|
|
110
|
+
sentry_level, level_tags = self._level(level)
|
|
111
|
+
tags.update(level_tags)
|
|
112
|
+
name = self._category(category)
|
|
113
|
+
if name is not None:
|
|
114
|
+
tags[CATEGORY_TAG] = name
|
|
115
|
+
return sentry_level, tags
|
|
116
|
+
|
|
117
|
+
def _category(self, category: Optional[str]) -> Optional[str]:
|
|
118
|
+
if category is None:
|
|
119
|
+
return None
|
|
120
|
+
if not is_category_name(category):
|
|
121
|
+
_warn(
|
|
122
|
+
"Ignored category %r: use 1 to 64 lowercase letters, digits, dots, "
|
|
123
|
+
"dashes or underscores.",
|
|
124
|
+
category,
|
|
125
|
+
)
|
|
126
|
+
return None
|
|
127
|
+
schema = self.schema
|
|
128
|
+
if schema is not None and category not in schema.categories:
|
|
129
|
+
_warn("Category %r is not in the schema; it is sent all the same.", category)
|
|
130
|
+
return category
|
|
131
|
+
|
|
132
|
+
def capture_exception(
|
|
133
|
+
self,
|
|
134
|
+
error: Error = None,
|
|
135
|
+
level: Union[L, SentryLevel, None] = None,
|
|
136
|
+
category: Optional[C] = None,
|
|
137
|
+
fields: Optional[Mapping[str, FieldValue]] = None,
|
|
138
|
+
**scope_kwargs: Any,
|
|
139
|
+
) -> Optional[str]:
|
|
140
|
+
"""sentry_sdk.capture_exception with a level, a category and fields.
|
|
141
|
+
|
|
142
|
+
Without an error, the one being handled is captured. Returns the
|
|
143
|
+
event id, or None when nothing was sent.
|
|
144
|
+
"""
|
|
145
|
+
sentry_level, tags = self._tags(level, category, fields)
|
|
146
|
+
return sentry_sdk.capture_exception(error, **_with(scope_kwargs, sentry_level, tags))
|
|
147
|
+
|
|
148
|
+
def capture_message(
|
|
149
|
+
self,
|
|
150
|
+
message: str,
|
|
151
|
+
level: Union[L, SentryLevel, None] = None,
|
|
152
|
+
category: Optional[C] = None,
|
|
153
|
+
fields: Optional[Mapping[str, FieldValue]] = None,
|
|
154
|
+
**scope_kwargs: Any,
|
|
155
|
+
) -> Optional[str]:
|
|
156
|
+
"""sentry_sdk.capture_message with a level, a category and fields."""
|
|
157
|
+
sentry_level, tags = self._tags(level, category, fields)
|
|
158
|
+
return sentry_sdk.capture_message(message, **_with(scope_kwargs, sentry_level, tags))
|
|
159
|
+
|
|
160
|
+
def set_category(self, category: Optional[C], scope: Optional[sentry_sdk.Scope] = None) -> None:
|
|
161
|
+
"""Gives later events a category: on `scope`, else the isolation scope
|
|
162
|
+
as sentry_sdk.set_tag does. None takes it off."""
|
|
163
|
+
target = scope if scope is not None else sentry_sdk.Scope.get_isolation_scope()
|
|
164
|
+
if category is None:
|
|
165
|
+
target.remove_tag(CATEGORY_TAG)
|
|
166
|
+
return
|
|
167
|
+
name = self._category(category)
|
|
168
|
+
if name is not None:
|
|
169
|
+
target.set_tag(CATEGORY_TAG, name)
|
|
170
|
+
|
|
171
|
+
def _before_send(self, event: Dict[str, Any], hint: Any) -> Dict[str, Any]:
|
|
172
|
+
"""Sends an event whose level is one of the schema's own (as
|
|
173
|
+
scope.set_level or sentry_sdk.capture_message can leave it) as its
|
|
174
|
+
Sentry level with the statoss.level tag."""
|
|
175
|
+
level = event.get("level")
|
|
176
|
+
schema = self.schema
|
|
177
|
+
if isinstance(level, str) and schema is not None:
|
|
178
|
+
sent_as = schema.sentry_level(level)
|
|
179
|
+
if sent_as is not None:
|
|
180
|
+
event["level"] = sent_as
|
|
181
|
+
tags = event.get("tags")
|
|
182
|
+
event["tags"] = {**(tags if isinstance(tags, dict) else {}), LEVEL_TAG: level}
|
|
183
|
+
return event
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _with(
|
|
187
|
+
scope_kwargs: Dict[str, Any], level: Optional[str], tags: Dict[str, str]
|
|
188
|
+
) -> Dict[str, Any]:
|
|
189
|
+
"""The caller's scope arguments with the level and tags put last.
|
|
190
|
+
|
|
191
|
+
They go on through a scope callback, which sentry-sdk applies to the
|
|
192
|
+
event's scope after the current ones, so the level given here wins
|
|
193
|
+
over one set on a scope, as in @statoss/sdk.
|
|
194
|
+
"""
|
|
195
|
+
if level is None and not tags:
|
|
196
|
+
return scope_kwargs
|
|
197
|
+
kwargs = dict(scope_kwargs)
|
|
198
|
+
scope = kwargs.pop("scope", None)
|
|
199
|
+
if scope is not None and kwargs:
|
|
200
|
+
raise TypeError("cannot provide scope and kwargs")
|
|
201
|
+
|
|
202
|
+
def apply(final: sentry_sdk.Scope) -> None:
|
|
203
|
+
if scope is None:
|
|
204
|
+
final.update_from_kwargs(**kwargs)
|
|
205
|
+
elif callable(scope):
|
|
206
|
+
scope(final)
|
|
207
|
+
else:
|
|
208
|
+
final.update_from_scope(scope)
|
|
209
|
+
final.update_from_kwargs(level=level, tags=tags) # type: ignore[arg-type]
|
|
210
|
+
|
|
211
|
+
return {"scope": apply}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
_current: Client[Any, Any] = Client()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@overload
|
|
218
|
+
def init(
|
|
219
|
+
dsn: Optional[str] = None, schema: None = None, **sentry_options: Any
|
|
220
|
+
) -> Client[str, str]: ...
|
|
221
|
+
@overload
|
|
222
|
+
def init(dsn: Optional[str], schema: Schema[L, C], **sentry_options: Any) -> Client[L, C]: ...
|
|
223
|
+
@overload
|
|
224
|
+
def init(
|
|
225
|
+
dsn: Optional[str] = None, *, schema: Schema[L, C], **sentry_options: Any
|
|
226
|
+
) -> Client[L, C]: ...
|
|
227
|
+
def init(
|
|
228
|
+
dsn: Optional[str] = None, schema: Optional[Schema[Any, Any]] = None, **sentry_options: Any
|
|
229
|
+
) -> Client[Any, Any]:
|
|
230
|
+
"""sentry_sdk.init, and the capture functions for the schema's levels.
|
|
231
|
+
|
|
232
|
+
The options are sentry-sdk's. The DSN is on the app's setup page in
|
|
233
|
+
StatOSS: https://<key>@ingest.statoss.com/<app number>.
|
|
234
|
+
"""
|
|
235
|
+
global _current
|
|
236
|
+
client: Client[Any, Any] = Client(schema)
|
|
237
|
+
if schema is not None and schema._sent_as:
|
|
238
|
+
sentry_options["before_send"] = _then(
|
|
239
|
+
sentry_options.get("before_send"), client._before_send
|
|
240
|
+
)
|
|
241
|
+
sentry_sdk.init(dsn, **sentry_options)
|
|
242
|
+
_current = client
|
|
243
|
+
return client
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _then(
|
|
247
|
+
first: Optional[Callable[[Any, Any], Any]], second: Callable[[Any, Any], Any]
|
|
248
|
+
) -> Callable[[Any, Any], Any]:
|
|
249
|
+
if first is None:
|
|
250
|
+
return second
|
|
251
|
+
|
|
252
|
+
def before_send(event: Any, hint: Any) -> Any:
|
|
253
|
+
event = first(event, hint)
|
|
254
|
+
return None if event is None else second(event, hint)
|
|
255
|
+
|
|
256
|
+
return before_send
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def capture_exception(
|
|
260
|
+
error: Error = None,
|
|
261
|
+
level: Optional[str] = None,
|
|
262
|
+
category: Optional[str] = None,
|
|
263
|
+
fields: Optional[Mapping[str, FieldValue]] = None,
|
|
264
|
+
**scope_kwargs: Any,
|
|
265
|
+
) -> Optional[str]:
|
|
266
|
+
"""Captures an exception (the one being handled, without one) with a
|
|
267
|
+
level, a category and fields. Returns the event id."""
|
|
268
|
+
return _current.capture_exception(error, level, category, fields, **scope_kwargs)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def capture_message(
|
|
272
|
+
message: str,
|
|
273
|
+
level: Optional[str] = None,
|
|
274
|
+
category: Optional[str] = None,
|
|
275
|
+
fields: Optional[Mapping[str, FieldValue]] = None,
|
|
276
|
+
**scope_kwargs: Any,
|
|
277
|
+
) -> Optional[str]:
|
|
278
|
+
"""Captures a message with a level, a category and fields."""
|
|
279
|
+
return _current.capture_message(message, level, category, fields, **scope_kwargs)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def set_category(category: Optional[str], scope: Optional[sentry_sdk.Scope] = None) -> None:
|
|
283
|
+
"""Gives later events a category; None takes it off."""
|
|
284
|
+
_current.set_category(category, scope)
|
statoss/_schema.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""An app's levels, categories, fields and rules, declared in code.
|
|
2
|
+
|
|
3
|
+
The shape follows the server's desired schema (readDesired in
|
|
4
|
+
src/lib/app-schema.ts and docs/sdk-and-cli.md), and the checks follow what
|
|
5
|
+
the server accepts, so a schema that passes here passes there.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
from typing import (
|
|
14
|
+
Any,
|
|
15
|
+
Dict,
|
|
16
|
+
Generic,
|
|
17
|
+
List,
|
|
18
|
+
Literal,
|
|
19
|
+
Mapping,
|
|
20
|
+
Optional,
|
|
21
|
+
Sequence,
|
|
22
|
+
Tuple,
|
|
23
|
+
Union,
|
|
24
|
+
get_args,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from typing_extensions import TypeVar
|
|
28
|
+
|
|
29
|
+
SentryLevel = Literal["fatal", "critical", "error", "warning", "info", "debug"]
|
|
30
|
+
"""The levels sentry-sdk sends. StatOSS reads critical as fatal."""
|
|
31
|
+
|
|
32
|
+
Like = Literal["fatal", "error", "warning", "info", "debug", "trace"]
|
|
33
|
+
"""OpenTelemetry's six severity ranges, which an app's own level ranks inside."""
|
|
34
|
+
|
|
35
|
+
SchemaColor = Literal[
|
|
36
|
+
"crimson",
|
|
37
|
+
"red",
|
|
38
|
+
"orange",
|
|
39
|
+
"amber",
|
|
40
|
+
"yellow",
|
|
41
|
+
"green",
|
|
42
|
+
"aqua",
|
|
43
|
+
"teal",
|
|
44
|
+
"blue",
|
|
45
|
+
"violet",
|
|
46
|
+
"indigo",
|
|
47
|
+
"magenta",
|
|
48
|
+
"plum",
|
|
49
|
+
"grey",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
FieldType = Literal["text", "number"]
|
|
53
|
+
RuleSubject = Literal["tag", "logger", "exception", "culprit", "title", "message"]
|
|
54
|
+
RuleMatch = Literal["is", "starts", "contains"]
|
|
55
|
+
|
|
56
|
+
SENTRY_LEVELS: Tuple[str, ...] = get_args(SentryLevel)
|
|
57
|
+
LIKES: Tuple[str, ...] = get_args(Like)
|
|
58
|
+
SCHEMA_COLORS: Tuple[str, ...] = get_args(SchemaColor)
|
|
59
|
+
FIELD_TYPES: Tuple[str, ...] = get_args(FieldType)
|
|
60
|
+
RULE_SUBJECTS: Tuple[str, ...] = get_args(RuleSubject)
|
|
61
|
+
RULE_MATCHES: Tuple[str, ...] = get_args(RuleMatch)
|
|
62
|
+
|
|
63
|
+
LEVEL_TAG = "statoss.level"
|
|
64
|
+
CATEGORY_TAG = "statoss.category"
|
|
65
|
+
|
|
66
|
+
#: Each range's severity numbers, highest first.
|
|
67
|
+
SEVERITY_RANGES: Dict[str, Tuple[int, int]] = {
|
|
68
|
+
"fatal": (21, 24),
|
|
69
|
+
"error": (17, 20),
|
|
70
|
+
"warning": (13, 16),
|
|
71
|
+
"info": (9, 12),
|
|
72
|
+
"debug": (5, 8),
|
|
73
|
+
"trace": (1, 4),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#: Sentry's five levels: always on the server, with these severities.
|
|
77
|
+
BUILTIN_LEVELS: Dict[str, int] = {
|
|
78
|
+
"fatal": 21,
|
|
79
|
+
"error": 17,
|
|
80
|
+
"warning": 13,
|
|
81
|
+
"info": 9,
|
|
82
|
+
"debug": 5,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#: The Sentry level an event of a level ranking like this is sent as.
|
|
86
|
+
NEAREST_SENTRY_LEVEL: Dict[str, str] = {
|
|
87
|
+
"fatal": "fatal",
|
|
88
|
+
"error": "error",
|
|
89
|
+
"warning": "warning",
|
|
90
|
+
"info": "info",
|
|
91
|
+
"debug": "debug",
|
|
92
|
+
"trace": "debug",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
KEY_MAX = {"level": 32, "category": 64, "field": 200}
|
|
96
|
+
CAPS = {"level": 50, "category": 200, "field": 300}
|
|
97
|
+
RULES_MAX = 50
|
|
98
|
+
LABEL_MAX = 60
|
|
99
|
+
UNIT_MAX = 16
|
|
100
|
+
OWNER_MAX = 320
|
|
101
|
+
RULE_VALUE_MAX = 200
|
|
102
|
+
|
|
103
|
+
_LEVEL_NAME = re.compile(r"[a-z0-9._-]{1,32}")
|
|
104
|
+
_CATEGORY_NAME = re.compile(r"[a-z0-9._-]{1,64}")
|
|
105
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
|
106
|
+
|
|
107
|
+
# A schema's level and category names; str (any name) unless typed with Literal.
|
|
108
|
+
L = TypeVar("L", bound=str, default=str)
|
|
109
|
+
C = TypeVar("C", bound=str, default=str)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SchemaError(ValueError):
|
|
113
|
+
"""A schema, or a part of one, that StatOSS would refuse."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_level_name(value: object) -> bool:
|
|
117
|
+
"""1 to 32 lowercase letters, digits, dots, dashes or underscores."""
|
|
118
|
+
return isinstance(value, str) and _LEVEL_NAME.fullmatch(value) is not None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def is_category_name(value: object) -> bool:
|
|
122
|
+
"""1 to 64 lowercase letters, digits, dots, dashes or underscores."""
|
|
123
|
+
return isinstance(value, str) and _CATEGORY_NAME.fullmatch(value) is not None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _text(value: object, most: int, what: str) -> None:
|
|
127
|
+
if value is None:
|
|
128
|
+
return
|
|
129
|
+
if not isinstance(value, str):
|
|
130
|
+
raise SchemaError(f"{what} must be text, not {type(value).__name__}.")
|
|
131
|
+
text = value.strip()
|
|
132
|
+
if text == "":
|
|
133
|
+
raise SchemaError(f"{what} is empty; leave it out instead.")
|
|
134
|
+
if len(text) > most:
|
|
135
|
+
raise SchemaError(f"{what} is at most {most} characters.")
|
|
136
|
+
if _CONTROL.search(text):
|
|
137
|
+
raise SchemaError(f"{what} has a control character.")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _one_of(value: object, options: Tuple[str, ...], what: str, required: bool = False) -> None:
|
|
141
|
+
if (value is not None or required) and value not in options:
|
|
142
|
+
raise SchemaError(f"{what} must be one of {', '.join(options)}; got {value!r}.")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _flag(value: object, what: str) -> None:
|
|
146
|
+
if value is not None and not isinstance(value, bool):
|
|
147
|
+
raise SchemaError(f"{what} must be True or False.")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _range_of(severity: int) -> str:
|
|
151
|
+
for like, (low, high) in SEVERITY_RANGES.items():
|
|
152
|
+
if low <= severity <= high:
|
|
153
|
+
return like
|
|
154
|
+
return "error"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class Level:
|
|
159
|
+
"""A level of the app's own: the range it ranks like, and how it reads."""
|
|
160
|
+
|
|
161
|
+
like: Optional[Like] = None
|
|
162
|
+
label: Optional[str] = None
|
|
163
|
+
color: Optional[SchemaColor] = None
|
|
164
|
+
#: OpenTelemetry's severity number, 1 to 24, inside the range of `like`.
|
|
165
|
+
severity: Optional[int] = None
|
|
166
|
+
|
|
167
|
+
def __post_init__(self) -> None:
|
|
168
|
+
_one_of(self.like, LIKES, "Level like")
|
|
169
|
+
_text(self.label, LABEL_MAX, "Level label")
|
|
170
|
+
_one_of(self.color, SCHEMA_COLORS, "Level color")
|
|
171
|
+
s = self.severity
|
|
172
|
+
if s is not None:
|
|
173
|
+
if isinstance(s, bool) or not isinstance(s, int) or not 1 <= s <= 24:
|
|
174
|
+
raise SchemaError("Level severity must be a whole number from 1 to 24.")
|
|
175
|
+
if self.like is not None and _range_of(s) != self.like:
|
|
176
|
+
low, high = SEVERITY_RANGES[self.like]
|
|
177
|
+
raise SchemaError(
|
|
178
|
+
f"Level severity {s} is not in the {self.like} range; "
|
|
179
|
+
f"{self.like} runs {low} to {high}."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def ranks_like(self) -> Optional[str]:
|
|
184
|
+
"""The range it ranks in: `like`, else the range of `severity`."""
|
|
185
|
+
if self.like is not None:
|
|
186
|
+
return self.like
|
|
187
|
+
if self.severity is not None:
|
|
188
|
+
return _range_of(self.severity)
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def to_desired(self) -> Dict[str, Any]:
|
|
192
|
+
return _given(like=self.like, label=self.label, color=self.color, severity=self.severity)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True)
|
|
196
|
+
class Category:
|
|
197
|
+
"""A category: how it reads, who owns it, and whether lists show it."""
|
|
198
|
+
|
|
199
|
+
label: Optional[str] = None
|
|
200
|
+
color: Optional[SchemaColor] = None
|
|
201
|
+
hidden: Optional[bool] = None
|
|
202
|
+
#: A team member's email.
|
|
203
|
+
owner: Optional[str] = None
|
|
204
|
+
|
|
205
|
+
def __post_init__(self) -> None:
|
|
206
|
+
_text(self.label, LABEL_MAX, "Category label")
|
|
207
|
+
_one_of(self.color, SCHEMA_COLORS, "Category color")
|
|
208
|
+
_flag(self.hidden, "Category hidden")
|
|
209
|
+
_text(self.owner, OWNER_MAX, "Category owner")
|
|
210
|
+
|
|
211
|
+
def to_desired(self) -> Dict[str, Any]:
|
|
212
|
+
return _given(label=self.label, color=self.color, hidden=self.hidden, owner=self.owner)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True)
|
|
216
|
+
class Field:
|
|
217
|
+
"""A tag key: how it reads, and whether filters offer it."""
|
|
218
|
+
|
|
219
|
+
label: Optional[str] = None
|
|
220
|
+
type: Optional[FieldType] = None
|
|
221
|
+
unit: Optional[str] = None
|
|
222
|
+
filterable: Optional[bool] = None
|
|
223
|
+
|
|
224
|
+
def __post_init__(self) -> None:
|
|
225
|
+
_text(self.label, LABEL_MAX, "Field label")
|
|
226
|
+
_one_of(self.type, FIELD_TYPES, "Field type")
|
|
227
|
+
_text(self.unit, UNIT_MAX, "Field unit")
|
|
228
|
+
_flag(self.filterable, "Field filterable")
|
|
229
|
+
|
|
230
|
+
def to_desired(self) -> Dict[str, Any]:
|
|
231
|
+
return _given(label=self.label, type=self.type, unit=self.unit, filterable=self.filterable)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True)
|
|
235
|
+
class Rule:
|
|
236
|
+
"""Sets a level, a category or both on events where one thing matches.
|
|
237
|
+
|
|
238
|
+
`subject` is what is tested: a tag (named by `tag_key`), the logger, the
|
|
239
|
+
exception type, the culprit, the title or the message.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
subject: RuleSubject
|
|
243
|
+
value: str
|
|
244
|
+
match: RuleMatch = "is"
|
|
245
|
+
level: Optional[str] = None
|
|
246
|
+
category: Optional[str] = None
|
|
247
|
+
tag_key: Optional[str] = None
|
|
248
|
+
|
|
249
|
+
def __post_init__(self) -> None:
|
|
250
|
+
_one_of(self.subject, RULE_SUBJECTS, "Rule subject", required=True)
|
|
251
|
+
_one_of(self.match, RULE_MATCHES, "Rule match", required=True)
|
|
252
|
+
if self.value is None:
|
|
253
|
+
raise SchemaError("Rule value is required.")
|
|
254
|
+
_text(self.value, RULE_VALUE_MAX, "Rule value")
|
|
255
|
+
if self.subject == "tag":
|
|
256
|
+
if self.tag_key is None:
|
|
257
|
+
raise SchemaError('Rule tag_key is required when the subject is "tag".')
|
|
258
|
+
_field_key(self.tag_key, "Rule tag_key")
|
|
259
|
+
elif self.tag_key is not None:
|
|
260
|
+
raise SchemaError('Rule tag_key is only for the subject "tag".')
|
|
261
|
+
if self.level is None and self.category is None:
|
|
262
|
+
raise SchemaError("A rule sets a level, a category or both.")
|
|
263
|
+
if self.level is not None and not is_level_name(self.level):
|
|
264
|
+
raise SchemaError(f"Rule level {self.level!r} is not a level name: {_LEVEL_HELP}")
|
|
265
|
+
if self.category is not None and not is_category_name(self.category):
|
|
266
|
+
raise SchemaError(
|
|
267
|
+
f"Rule category {self.category!r} is not a category name: {_CATEGORY_HELP}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def to_desired(self) -> Dict[str, Any]:
|
|
271
|
+
out: Dict[str, Any] = {"subject": self.subject}
|
|
272
|
+
if self.subject == "tag":
|
|
273
|
+
out["tagKey"] = self.tag_key
|
|
274
|
+
out["match"] = self.match
|
|
275
|
+
out["value"] = self.value
|
|
276
|
+
out.update(_given(level=self.level, category=self.category))
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
_LEVEL_HELP = "use 1 to 32 lowercase letters, digits, dots, dashes or underscores."
|
|
281
|
+
_CATEGORY_HELP = "use 1 to 64 lowercase letters, digits, dots, dashes or underscores."
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _given(**settings: Any) -> Dict[str, Any]:
|
|
285
|
+
return {k: v for k, v in settings.items() if v is not None}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _field_key(key: object, what: str) -> None:
|
|
289
|
+
if not isinstance(key, str):
|
|
290
|
+
raise SchemaError(f"{what} must be text, not {type(key).__name__}.")
|
|
291
|
+
if key != key.strip():
|
|
292
|
+
raise SchemaError(f"{what} {key!r} has spaces around it.")
|
|
293
|
+
if key == "":
|
|
294
|
+
raise SchemaError(f"{what} is empty.")
|
|
295
|
+
if len(key) > KEY_MAX["field"]:
|
|
296
|
+
raise SchemaError(f"{what} is at most {KEY_MAX['field']} characters.")
|
|
297
|
+
if _CONTROL.search(key):
|
|
298
|
+
raise SchemaError(f"{what} has a control character.")
|
|
299
|
+
if key.startswith("statoss."):
|
|
300
|
+
raise SchemaError(f"{what} {key!r}: tags starting with statoss. are not fields.")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class Schema(Generic[L, C]):
|
|
304
|
+
"""A schema made by `define_schema`. Level names are typed `L`, categories `C`."""
|
|
305
|
+
|
|
306
|
+
def __init__(
|
|
307
|
+
self,
|
|
308
|
+
levels: Dict[str, Level],
|
|
309
|
+
categories: Dict[str, Category],
|
|
310
|
+
fields: Dict[str, Field],
|
|
311
|
+
rules: Optional[Tuple[Rule, ...]],
|
|
312
|
+
given: Tuple[bool, bool, bool],
|
|
313
|
+
) -> None:
|
|
314
|
+
self.levels: Mapping[str, Level] = MappingProxyType(levels)
|
|
315
|
+
self.categories: Mapping[str, Category] = MappingProxyType(categories)
|
|
316
|
+
self.fields: Mapping[str, Field] = MappingProxyType(fields)
|
|
317
|
+
#: None when the code says nothing of rules, so the server leaves them be.
|
|
318
|
+
self.rules = rules
|
|
319
|
+
self._given = given
|
|
320
|
+
self._sent_as = {
|
|
321
|
+
name: NEAREST_SENTRY_LEVEL[level.ranks_like or "error"]
|
|
322
|
+
for name, level in levels.items()
|
|
323
|
+
if name not in BUILTIN_LEVELS
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
def sentry_level(self, name: str) -> Optional[str]:
|
|
327
|
+
"""The Sentry level an app's own level is sent as; None for others."""
|
|
328
|
+
return self._sent_as.get(name)
|
|
329
|
+
|
|
330
|
+
def to_desired(self) -> Dict[str, Any]:
|
|
331
|
+
"""The schema as the server's desired state, ready for JSON."""
|
|
332
|
+
out: Dict[str, Any] = {}
|
|
333
|
+
has_levels, has_categories, has_fields = self._given
|
|
334
|
+
if has_levels:
|
|
335
|
+
out["levels"] = {k: v.to_desired() for k, v in self.levels.items()}
|
|
336
|
+
if has_categories:
|
|
337
|
+
out["categories"] = {k: v.to_desired() for k, v in self.categories.items()}
|
|
338
|
+
if has_fields:
|
|
339
|
+
out["fields"] = {k: v.to_desired() for k, v in self.fields.items()}
|
|
340
|
+
if self.rules is not None:
|
|
341
|
+
out["rules"] = [r.to_desired() for r in self.rules]
|
|
342
|
+
return out
|
|
343
|
+
|
|
344
|
+
def __repr__(self) -> str:
|
|
345
|
+
return f"Schema({self.to_desired()!r})"
|
|
346
|
+
|
|
347
|
+
def __eq__(self, other: object) -> bool:
|
|
348
|
+
return isinstance(other, Schema) and other.to_desired() == self.to_desired()
|
|
349
|
+
|
|
350
|
+
__hash__ = None # type: ignore[assignment]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def define_schema(
|
|
354
|
+
levels: Optional[Mapping[L, Union[Level, Like]]] = None,
|
|
355
|
+
categories: Union[Mapping[C, Category], Sequence[C], None] = None,
|
|
356
|
+
fields: Union[Mapping[str, Field], Sequence[str], None] = None,
|
|
357
|
+
rules: Optional[Sequence[Rule]] = None,
|
|
358
|
+
) -> Schema[L, C]:
|
|
359
|
+
"""An app's levels, categories, fields and rules, checked as the server checks them.
|
|
360
|
+
|
|
361
|
+
A level may be given as the range it ranks like (`"slow-query": "warning"`),
|
|
362
|
+
categories and fields as a list of names. Raises SchemaError naming the
|
|
363
|
+
place of the first problem.
|
|
364
|
+
"""
|
|
365
|
+
level_map = _levels(levels)
|
|
366
|
+
category_map = _names("category", categories)
|
|
367
|
+
rule_list = _rules(rules)
|
|
368
|
+
# A rule names the schema's own levels and categories, as the
|
|
369
|
+
# TypeScript types require: one it does not hold could not be pruned.
|
|
370
|
+
for i, rule in enumerate(rule_list or ()):
|
|
371
|
+
if (
|
|
372
|
+
rule.level is not None
|
|
373
|
+
and rule.level not in SENTRY_LEVELS
|
|
374
|
+
and rule.level not in level_map
|
|
375
|
+
):
|
|
376
|
+
raise SchemaError(f"rules[{i}]: {rule.level!r} is not a level of the schema.")
|
|
377
|
+
if rule.category is not None and rule.category not in category_map:
|
|
378
|
+
raise SchemaError(f"rules[{i}]: {rule.category!r} is not a category of the schema.")
|
|
379
|
+
return Schema(
|
|
380
|
+
level_map,
|
|
381
|
+
category_map,
|
|
382
|
+
_names("field", fields),
|
|
383
|
+
rule_list,
|
|
384
|
+
(levels is not None, categories is not None, fields is not None),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _levels(raw: object) -> Dict[str, Level]:
|
|
389
|
+
out: Dict[str, Level] = {}
|
|
390
|
+
if raw is None:
|
|
391
|
+
return out
|
|
392
|
+
if not isinstance(raw, Mapping):
|
|
393
|
+
raise SchemaError(
|
|
394
|
+
'levels must be a dict of names, such as {"payment-failed": Level(like="error")}.'
|
|
395
|
+
)
|
|
396
|
+
if len(raw) > CAPS["level"]:
|
|
397
|
+
raise SchemaError(f"levels holds at most {CAPS['level']} levels.")
|
|
398
|
+
for name, value in raw.items():
|
|
399
|
+
where = f"levels[{name!r}]"
|
|
400
|
+
if not is_level_name(name):
|
|
401
|
+
raise SchemaError(f"{where}: not a level name; {_LEVEL_HELP}")
|
|
402
|
+
if name == "critical":
|
|
403
|
+
raise SchemaError(f"{where}: critical is sent as a Sentry level and read as fatal.")
|
|
404
|
+
if isinstance(value, str):
|
|
405
|
+
if value not in LIKES:
|
|
406
|
+
raise SchemaError(
|
|
407
|
+
f"{where}: like must be one of {', '.join(LIKES)}; got {value!r}."
|
|
408
|
+
)
|
|
409
|
+
value = Level(like=value) # type: ignore[arg-type]
|
|
410
|
+
elif not isinstance(value, Level):
|
|
411
|
+
raise SchemaError(f"{where}: give a Level or the range it ranks like, such as 'error'.")
|
|
412
|
+
if name in BUILTIN_LEVELS:
|
|
413
|
+
if value.like is not None and value.like != name:
|
|
414
|
+
raise SchemaError(f"{where}: the built-in level {name} ranks as {name}.")
|
|
415
|
+
if value.severity is not None and value.severity != BUILTIN_LEVELS[name]:
|
|
416
|
+
raise SchemaError(
|
|
417
|
+
f"{where}: the built-in level {name} keeps severity {BUILTIN_LEVELS[name]}."
|
|
418
|
+
)
|
|
419
|
+
elif value.ranks_like is None:
|
|
420
|
+
raise SchemaError(f"{where}: give like, the range it ranks like ({', '.join(LIKES)}).")
|
|
421
|
+
out[name] = value
|
|
422
|
+
return out
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _names(kind: str, raw: object) -> Dict[str, Any]:
|
|
426
|
+
plural = "categories" if kind == "category" else "fields"
|
|
427
|
+
settings_type = Category if kind == "category" else Field
|
|
428
|
+
out: Dict[str, Any] = {}
|
|
429
|
+
if raw is None:
|
|
430
|
+
return out
|
|
431
|
+
entries: List[Tuple[object, object]]
|
|
432
|
+
if isinstance(raw, Mapping):
|
|
433
|
+
entries = list(raw.items())
|
|
434
|
+
elif isinstance(raw, (list, tuple)):
|
|
435
|
+
entries = [(name, settings_type()) for name in raw]
|
|
436
|
+
else:
|
|
437
|
+
raise SchemaError(
|
|
438
|
+
f"{plural} must be a list of names or a dict of names and "
|
|
439
|
+
f"{settings_type.__name__} settings."
|
|
440
|
+
)
|
|
441
|
+
if len(entries) > CAPS[kind]:
|
|
442
|
+
raise SchemaError(f"{plural} holds at most {CAPS[kind]} {plural}.")
|
|
443
|
+
for name, value in entries:
|
|
444
|
+
where = f"{plural}[{name!r}]"
|
|
445
|
+
if kind == "category":
|
|
446
|
+
if not is_category_name(name):
|
|
447
|
+
raise SchemaError(f"{where}: not a category name; {_CATEGORY_HELP}")
|
|
448
|
+
else:
|
|
449
|
+
_field_key(name, where)
|
|
450
|
+
if not isinstance(value, settings_type):
|
|
451
|
+
raise SchemaError(f"{where}: give a {settings_type.__name__}.")
|
|
452
|
+
if name in out:
|
|
453
|
+
raise SchemaError(f"{where} is named twice.")
|
|
454
|
+
out[name] = value # type: ignore[index]
|
|
455
|
+
return out
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _rules(raw: object) -> Optional[Tuple[Rule, ...]]:
|
|
459
|
+
if raw is None:
|
|
460
|
+
return None
|
|
461
|
+
if not isinstance(raw, (list, tuple)):
|
|
462
|
+
raise SchemaError("rules must be a list of Rule.")
|
|
463
|
+
if len(raw) > RULES_MAX:
|
|
464
|
+
raise SchemaError(f"rules holds at most {RULES_MAX} rules.")
|
|
465
|
+
for i, rule in enumerate(raw):
|
|
466
|
+
if not isinstance(rule, Rule):
|
|
467
|
+
raise SchemaError(f"rules[{i}]: give a Rule.")
|
|
468
|
+
return tuple(raw)
|
statoss/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: statoss
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Error tracking for StatOSS: sentry-sdk with your app's own levels, categories and fields.
|
|
5
|
+
Keywords: statoss,errors,error-tracking,sentry,monitoring
|
|
6
|
+
Author: StatOSS
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: System :: Monitoring
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Dist: sentry-sdk>=2.0.0,<3
|
|
17
|
+
Requires-Dist: typing-extensions>=4.12
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Project-URL: Homepage, https://statoss.com
|
|
20
|
+
Project-URL: Source, https://github.com/kroqdotdev/statoss/tree/main/sdk-python
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# statoss
|
|
24
|
+
|
|
25
|
+
Error tracking for [StatOSS](https://statoss.com) in Python: a thin layer
|
|
26
|
+
over [`sentry-sdk`](https://pypi.org/project/sentry-sdk/) that adds your
|
|
27
|
+
app's own levels, categories and fields. Python 3.9 or later, sentry-sdk 2.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install statoss # or: uv add statoss, poetry add statoss
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Start
|
|
36
|
+
|
|
37
|
+
Call `init` once, as early as you can, with your app's DSN. The DSN is on the
|
|
38
|
+
app's setup page in StatOSS and looks like
|
|
39
|
+
`https://<key>@ingest.statoss.com/<app number>`.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import os
|
|
43
|
+
import statoss
|
|
44
|
+
|
|
45
|
+
statoss.init(
|
|
46
|
+
dsn=os.environ["STATOSS_DSN"],
|
|
47
|
+
environment="production",
|
|
48
|
+
release="shop@1.4.0",
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The options are sentry-sdk's, and without a schema `statoss.init` is
|
|
53
|
+
`sentry_sdk.init`: integrations, scopes and everything else work as its
|
|
54
|
+
documentation says. `statoss.flush`, `set_user`, `set_tag`, `set_context` and
|
|
55
|
+
`add_breadcrumb` are sentry-sdk's own; import `sentry_sdk` for the rest.
|
|
56
|
+
|
|
57
|
+
## Levels, categories and fields
|
|
58
|
+
|
|
59
|
+
Sentry has five levels: `fatal`, `error`, `warning`, `info` and `debug`.
|
|
60
|
+
StatOSS lets you name your own, and adds a category and fields.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
schema = statoss.define_schema(
|
|
64
|
+
levels={
|
|
65
|
+
"payment-failed": statoss.Level(like="error", label="Payment failed"),
|
|
66
|
+
"slow-query": "warning",
|
|
67
|
+
},
|
|
68
|
+
categories={
|
|
69
|
+
"billing": statoss.Category(label="Billing", color="plum"),
|
|
70
|
+
"auth": statoss.Category(),
|
|
71
|
+
},
|
|
72
|
+
fields={
|
|
73
|
+
"route": statoss.Field(label="Route", filterable=True),
|
|
74
|
+
"duration_ms": statoss.Field(type="number", unit="ms", label="Duration"),
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
statoss.init(dsn=os.environ["STATOSS_DSN"], schema=schema)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
charge(order)
|
|
82
|
+
except CardError as error:
|
|
83
|
+
statoss.capture_exception(
|
|
84
|
+
error, level="payment-failed", category="billing", fields={"route": "/pay"}
|
|
85
|
+
)
|
|
86
|
+
# level "error", tags statoss.level=payment-failed, statoss.category=billing, route=/pay
|
|
87
|
+
|
|
88
|
+
statoss.capture_message("Search took 2.4 s", level="slow-query", fields={"duration_ms": 2400})
|
|
89
|
+
# level "warning", tags statoss.level=slow-query, duration_ms=2400
|
|
90
|
+
|
|
91
|
+
statoss.set_category("billing") # every later event in this scope
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- An event is sent at the nearest Sentry level, with its own level in the tag
|
|
95
|
+
`statoss.level` and its category in `statoss.category`, so Sentry and other
|
|
96
|
+
Sentry-compatible tools still read it. Fields are sent as tags.
|
|
97
|
+
- `level` takes a Sentry level or a level of the schema. A name the schema
|
|
98
|
+
does not hold is sent as `error` and, with `debug=True`, logged. A level or
|
|
99
|
+
category with characters a name cannot have (see below) is left out and
|
|
100
|
+
logged the same way.
|
|
101
|
+
- The level given to a capture wins over a level set on a scope.
|
|
102
|
+
- `set_category(name, scope=None)` sets it on the scope you pass, else on the
|
|
103
|
+
isolation scope as `sentry_sdk.set_tag` does. `set_category(None)` takes it
|
|
104
|
+
off.
|
|
105
|
+
- An event whose level is one of the schema's own, as `scope.set_level` or
|
|
106
|
+
`sentry_sdk.capture_message` may leave it, is sent the same way, after your
|
|
107
|
+
`before_send`.
|
|
108
|
+
|
|
109
|
+
## The schema
|
|
110
|
+
|
|
111
|
+
`define_schema` checks everything as StatOSS does and raises `SchemaError`
|
|
112
|
+
naming the place of the first problem.
|
|
113
|
+
|
|
114
|
+
| Kind | Name | Settings |
|
|
115
|
+
| -------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
116
|
+
| Level | 1 to 32 lowercase letters, digits, dots, dashes or underscores | `like` (`fatal`, `error`, `warning`, `info`, `debug` or `trace`), `label`, `color`, `severity` (1 to 24, inside the `like` range) |
|
|
117
|
+
| Category | 1 to 64 of the same | `label`, `color`, `hidden`, `owner` (a team member's email) |
|
|
118
|
+
| Field | any tag key not starting `statoss.` | `label`, `type` (`text` or `number`), `unit`, `filterable` |
|
|
119
|
+
|
|
120
|
+
- A level may be given as its `like` alone (`"slow-query": "warning"`);
|
|
121
|
+
categories and fields as a list of names (`categories=["billing", "auth"]`).
|
|
122
|
+
- A level ranking like `trace` is sent as `debug`. Sentry's own levels take a
|
|
123
|
+
label and a colour (`"error": statoss.Level(label="Error")`).
|
|
124
|
+
- Colours: crimson, red, orange, amber, yellow, green, aqua, teal, blue,
|
|
125
|
+
violet, indigo, magenta, plum, grey.
|
|
126
|
+
- Rules sort events that name no level or category of their own:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
rules = [
|
|
130
|
+
statoss.Rule(subject="logger", value="payments", category="billing"),
|
|
131
|
+
statoss.Rule(subject="tag", tag_key="db", match="starts", value="pg", level="slow-query"),
|
|
132
|
+
]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`subject` is `tag`, `logger`, `exception`, `culprit`, `title` or
|
|
136
|
+
`message`; `match` is `is` (the default), `starts` or `contains`.
|
|
137
|
+
|
|
138
|
+
`schema.to_desired()` gives the schema as StatOSS's desired state, the JSON
|
|
139
|
+
that `POST /api/v1/apps/{app}/schema/plan` takes as `desired`. Rules left out
|
|
140
|
+
(`rules=None`) leave the app's rules as they are; `rules=[]` says there are
|
|
141
|
+
none.
|
|
142
|
+
|
|
143
|
+
## Typing
|
|
144
|
+
|
|
145
|
+
The package ships type hints. Name your levels and categories with `Literal`
|
|
146
|
+
and a type checker (mypy, pyright) refuses a name the schema does not hold:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from typing import Literal
|
|
150
|
+
|
|
151
|
+
import statoss
|
|
152
|
+
|
|
153
|
+
LevelName = Literal["payment-failed", "slow-query"]
|
|
154
|
+
CategoryName = Literal["billing", "auth"]
|
|
155
|
+
|
|
156
|
+
schema: statoss.Schema[LevelName, CategoryName] = statoss.define_schema(
|
|
157
|
+
levels={"payment-failed": "error", "slow-query": "warning"},
|
|
158
|
+
categories=["billing", "auth"],
|
|
159
|
+
)
|
|
160
|
+
client = statoss.init(dsn=os.environ["STATOSS_DSN"], schema=schema)
|
|
161
|
+
|
|
162
|
+
client.capture_exception(error, level="payment-failed", category="billing")
|
|
163
|
+
client.capture_exception(error, level="payment-faild") # error: not a LevelName
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`init` returns a `statoss.Client[LevelName, CategoryName]` whose
|
|
167
|
+
`capture_exception`, `capture_message` and `set_category` take only those
|
|
168
|
+
names and Sentry's levels. The module functions take any name.
|
|
169
|
+
|
|
170
|
+
## Developing
|
|
171
|
+
|
|
172
|
+
From `sdk-python/`, with [uv](https://docs.astral.sh/uv/):
|
|
173
|
+
|
|
174
|
+
```sh
|
|
175
|
+
uv run pytest # tests, including the mypy check of tests/typing_check.py
|
|
176
|
+
uv run --with pyright pytest # the same, with pyright as well
|
|
177
|
+
uv build # wheel and sdist in dist/
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`tests/fixtures` holds a schema's desired state and the lists and limits the
|
|
181
|
+
package checks against; `src/lib/python-sdk-schema.test.ts` holds the server
|
|
182
|
+
to them. After changing either side, run `uv run python tests/sample_schema.py`
|
|
183
|
+
and `pnpm prettier --write sdk-python/tests/fixtures`.
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
statoss/__init__.py,sha256=MwBx1tQqGlJ3JUj0lGbAdA3c38vj0mCBoxkiyWUerjc,1025
|
|
2
|
+
statoss/_client.py,sha256=wZ-AWqxv7rS6eITUyYecuH-n_sbfq5rL4HkLLcM24Sc,9740
|
|
3
|
+
statoss/_schema.py,sha256=gfLliMF-veNFHehAQVyTBDw8nVIfz8LAFzx3y3DzivQ,17011
|
|
4
|
+
statoss/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
statoss-0.1.0.dist-info/licenses/LICENSE,sha256=BmZH0-DV0qH-I5lzCxExeH6ImjfBTiucfAA7HYJvqck,1064
|
|
6
|
+
statoss-0.1.0.dist-info/WHEEL,sha256=cmC5s21ojypbVslldL7IJq3hjZH-tINy4rziKePFsG0,81
|
|
7
|
+
statoss-0.1.0.dist-info/METADATA,sha256=r2MHjfwX8tK_MZIr0l0ZRv8lO8u82Sud4MylAeVZTlY,7631
|
|
8
|
+
statoss-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 StatOSS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|