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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.23
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.