zen-anomaly-client 0.1.0__tar.gz
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.
- zen_anomaly_client-0.1.0/LICENSE.md +21 -0
- zen_anomaly_client-0.1.0/PKG-INFO +69 -0
- zen_anomaly_client-0.1.0/README.md +53 -0
- zen_anomaly_client-0.1.0/pyproject.toml +30 -0
- zen_anomaly_client-0.1.0/setup.cfg +4 -0
- zen_anomaly_client-0.1.0/src/zen_anomaly_client/__init__.py +28 -0
- zen_anomaly_client-0.1.0/src/zen_anomaly_client/codes.py +109 -0
- zen_anomaly_client-0.1.0/src/zen_anomaly_client/contract.py +163 -0
- zen_anomaly_client-0.1.0/src/zen_anomaly_client.egg-info/PKG-INFO +69 -0
- zen_anomaly_client-0.1.0/src/zen_anomaly_client.egg-info/SOURCES.txt +12 -0
- zen_anomaly_client-0.1.0/src/zen_anomaly_client.egg-info/dependency_links.txt +1 -0
- zen_anomaly_client-0.1.0/src/zen_anomaly_client.egg-info/requires.txt +4 -0
- zen_anomaly_client-0.1.0/src/zen_anomaly_client.egg-info/top_level.txt +1 -0
- zen_anomaly_client-0.1.0/tests/test_contract.py +205 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Well Checked Systems International
|
|
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.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zen-anomaly-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Anomaly event contract and client for the zen-space anomaly stream
|
|
5
|
+
Author-email: Hank Haines <hankhaines@wellchecked.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE.md
|
|
12
|
+
Requires-Dist: pydantic<3,>=2.5
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# zen-anomaly-client
|
|
18
|
+
|
|
19
|
+
The anomaly event contract for the zen-space platform, and (from Phase 3) the
|
|
20
|
+
client, wiring helpers and pytest harness an edge or service process uses to
|
|
21
|
+
report failures to the anomaly stream.
|
|
22
|
+
|
|
23
|
+
Plan and phases: `docs/ANOMALY_EVENT_STREAM_PLAN.md` in zen-space-network-api.
|
|
24
|
+
Edge contract (endpoints, codes, examples): `docs/contracts/ANOMALY_EDGE_CONTRACT.md`
|
|
25
|
+
in the same repository.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
pip install zen-anomaly-client
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Depends on `pydantic` only.
|
|
34
|
+
|
|
35
|
+
## The contract
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from zen_anomaly_client import AnomalyEvent, AnomalySource, Category, Severity, codes
|
|
39
|
+
|
|
40
|
+
event = AnomalyEvent(
|
|
41
|
+
source=AnomalySource(system="edge", instance="org1.box7", version="1.4.0"),
|
|
42
|
+
site_slug="gil",
|
|
43
|
+
category=Category.UPLOAD,
|
|
44
|
+
code=codes.EDGE_UPLOAD_FAILED,
|
|
45
|
+
severity=Severity.WARNING,
|
|
46
|
+
message="upload failed after 3 attempts",
|
|
47
|
+
details={"file": "w_Gil_camera0_20261112120001.mp4", "size": 12345, "error": "ReadTimeoutError"},
|
|
48
|
+
)
|
|
49
|
+
event.to_json() # the wire format
|
|
50
|
+
event.topic # "edge/edge.upload.failed"
|
|
51
|
+
event.dedupe_key # "edge:edge.upload.failed:gil" unless you set one
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Rules the model enforces: `code` is `system.subject.what` in lower snake case
|
|
55
|
+
(2–4 segments); `message` is stripped and cut at 500 characters; `details` is
|
|
56
|
+
one level deep, at most 32 keys, string values cut at 1024 characters; slugs
|
|
57
|
+
are lower-case; timestamps are normalised to UTC. Unknown fields are rejected.
|
|
58
|
+
|
|
59
|
+
`codes.CATALOGUE` lists every known code with its category, default severity
|
|
60
|
+
and meaning. Codes not in the catalogue are accepted but shown as "Unlisted".
|
|
61
|
+
|
|
62
|
+
## Development
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
python -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
|
66
|
+
.venv/bin/python -m pytest
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Release: `bin/pypi-build.sh` then `bin/pypi-upload.sh` (twine, PyPI credentials required).
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# zen-anomaly-client
|
|
2
|
+
|
|
3
|
+
The anomaly event contract for the zen-space platform, and (from Phase 3) the
|
|
4
|
+
client, wiring helpers and pytest harness an edge or service process uses to
|
|
5
|
+
report failures to the anomaly stream.
|
|
6
|
+
|
|
7
|
+
Plan and phases: `docs/ANOMALY_EVENT_STREAM_PLAN.md` in zen-space-network-api.
|
|
8
|
+
Edge contract (endpoints, codes, examples): `docs/contracts/ANOMALY_EDGE_CONTRACT.md`
|
|
9
|
+
in the same repository.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
pip install zen-anomaly-client
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Depends on `pydantic` only.
|
|
18
|
+
|
|
19
|
+
## The contract
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from zen_anomaly_client import AnomalyEvent, AnomalySource, Category, Severity, codes
|
|
23
|
+
|
|
24
|
+
event = AnomalyEvent(
|
|
25
|
+
source=AnomalySource(system="edge", instance="org1.box7", version="1.4.0"),
|
|
26
|
+
site_slug="gil",
|
|
27
|
+
category=Category.UPLOAD,
|
|
28
|
+
code=codes.EDGE_UPLOAD_FAILED,
|
|
29
|
+
severity=Severity.WARNING,
|
|
30
|
+
message="upload failed after 3 attempts",
|
|
31
|
+
details={"file": "w_Gil_camera0_20261112120001.mp4", "size": 12345, "error": "ReadTimeoutError"},
|
|
32
|
+
)
|
|
33
|
+
event.to_json() # the wire format
|
|
34
|
+
event.topic # "edge/edge.upload.failed"
|
|
35
|
+
event.dedupe_key # "edge:edge.upload.failed:gil" unless you set one
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Rules the model enforces: `code` is `system.subject.what` in lower snake case
|
|
39
|
+
(2–4 segments); `message` is stripped and cut at 500 characters; `details` is
|
|
40
|
+
one level deep, at most 32 keys, string values cut at 1024 characters; slugs
|
|
41
|
+
are lower-case; timestamps are normalised to UTC. Unknown fields are rejected.
|
|
42
|
+
|
|
43
|
+
`codes.CATALOGUE` lists every known code with its category, default severity
|
|
44
|
+
and meaning. Codes not in the catalogue are accepted but shown as "Unlisted".
|
|
45
|
+
|
|
46
|
+
## Development
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
python -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
|
50
|
+
.venv/bin/python -m pytest
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Release: `bin/pypi-build.sh` then `bin/pypi-upload.sh` (twine, PyPI credentials required).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zen-anomaly-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Anomaly event contract and client for the zen-space anomaly stream"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE.md"]
|
|
13
|
+
authors = [{ name = "Hank Haines", email = "hankhaines@wellchecked.com" }]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"pydantic>=2.5,<3",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = ["pytest>=7"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
pythonpath = ["src"]
|
|
30
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""zen-anomaly-client: the anomaly event contract shared by every zen-space process.
|
|
2
|
+
|
|
3
|
+
Phase 1 ships the contract only (`AnomalyEvent` and the code catalogue). The
|
|
4
|
+
HTTP client, the wiring helpers and the pytest harness follow in Phase 3
|
|
5
|
+
(docs/ANOMALY_EVENT_STREAM_PLAN.md in zen-space-network-api).
|
|
6
|
+
"""
|
|
7
|
+
from zen_anomaly_client.contract import (
|
|
8
|
+
ANOMALY_SCHEMA_VERSION,
|
|
9
|
+
AnomalyEvent,
|
|
10
|
+
AnomalySource,
|
|
11
|
+
Category,
|
|
12
|
+
Severity,
|
|
13
|
+
SEVERITY_ORDER,
|
|
14
|
+
severity_at_least,
|
|
15
|
+
)
|
|
16
|
+
from zen_anomaly_client import codes
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ANOMALY_SCHEMA_VERSION",
|
|
20
|
+
"AnomalyEvent",
|
|
21
|
+
"AnomalySource",
|
|
22
|
+
"Category",
|
|
23
|
+
"Severity",
|
|
24
|
+
"SEVERITY_ORDER",
|
|
25
|
+
"severity_at_least",
|
|
26
|
+
"codes",
|
|
27
|
+
]
|
|
28
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""The anomaly code catalogue.
|
|
2
|
+
|
|
3
|
+
A code is a stable dotted string `<system>.<subject>.<what>`; the first
|
|
4
|
+
segment is the reporting system. Producers may emit codes that are not listed
|
|
5
|
+
here (the contract only checks the shape), but every code that notifies
|
|
6
|
+
someone or appears in a runbook should be added with its category, default
|
|
7
|
+
severity and one-line meaning, so the manager page and the documentation
|
|
8
|
+
describe it the same way.
|
|
9
|
+
"""
|
|
10
|
+
from typing import Dict, NamedTuple
|
|
11
|
+
|
|
12
|
+
from zen_anomaly_client.contract import Category, Severity
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CodeInfo(NamedTuple):
|
|
16
|
+
category: Category
|
|
17
|
+
severity: Severity
|
|
18
|
+
description: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# --- edge -----------------------------------------------------------------
|
|
22
|
+
EDGE_UPLOAD_FAILED = "edge.upload.failed"
|
|
23
|
+
EDGE_UPLOAD_RECOVERED = "edge.upload.recovered"
|
|
24
|
+
EDGE_UPLOAD_GAVE_UP = "edge.upload.gave_up"
|
|
25
|
+
EDGE_SERVICE_CRASHED = "edge.service.crashed"
|
|
26
|
+
EDGE_SERVICE_RESTARTED = "edge.service.restarted"
|
|
27
|
+
EDGE_ANOMALY_SPOOL_DROPPED = "edge.anomaly.spool_dropped"
|
|
28
|
+
EDGE_PROBE = "edge.anomaly.probe"
|
|
29
|
+
|
|
30
|
+
# --- api ------------------------------------------------------------------
|
|
31
|
+
API_JOB_FAILED = "api.job.failed"
|
|
32
|
+
API_AUDIT_WRITE_FAILED = "api.audit.write_failed"
|
|
33
|
+
API_REQUEST_FAILED = "api.request.failed"
|
|
34
|
+
API_CACHE_FAILED = "api.cache.failed"
|
|
35
|
+
|
|
36
|
+
# --- lambda ---------------------------------------------------------------
|
|
37
|
+
LAMBDA_REDIS_PUSH_FAILED = "lambda.redis.push_failed"
|
|
38
|
+
LAMBDA_COPY_FAILED = "lambda.copy.failed"
|
|
39
|
+
|
|
40
|
+
# --- subscribers (router, indexer, notifier, tally, describer) ----------------
|
|
41
|
+
CONSUMER_STOPPED = "{system}.consumer.stopped"
|
|
42
|
+
MESSAGE_FAILED = "{system}.message_failed"
|
|
43
|
+
ROUTER_API_UNREACHABLE = "router.api.unreachable"
|
|
44
|
+
ROUTER_SITE_CAP_REFUSED = "router.site_cap_refused"
|
|
45
|
+
NOTIFIER_DELIVERY_EMAIL_FAILED = "notifier.delivery.email_failed"
|
|
46
|
+
NOTIFIER_DELIVERY_SMS_FAILED = "notifier.delivery.sms_failed"
|
|
47
|
+
NOTIFIER_SILENCE_CHECK_FAILED = "notifier.silence_check.failed"
|
|
48
|
+
NOTIFIER_ANOMALY_RECORD_FAILED = "notifier.anomaly_record_failed"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def consumer_stopped(system: str) -> str:
|
|
52
|
+
return CONSUMER_STOPPED.format(system=system)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def message_failed(system: str) -> str:
|
|
56
|
+
return MESSAGE_FAILED.format(system=system)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
CATALOGUE: Dict[str, CodeInfo] = {
|
|
60
|
+
EDGE_UPLOAD_FAILED: CodeInfo(Category.UPLOAD, Severity.WARNING,
|
|
61
|
+
"An upload to S3 failed after its retries; the file waits in errored/."),
|
|
62
|
+
EDGE_UPLOAD_RECOVERED: CodeInfo(Category.UPLOAD, Severity.INFO,
|
|
63
|
+
"A file from errored/ uploaded on a later rescan."),
|
|
64
|
+
EDGE_UPLOAD_GAVE_UP: CodeInfo(Category.UPLOAD, Severity.ERROR,
|
|
65
|
+
"A file exhausted its rescan budget and will not be uploaded."),
|
|
66
|
+
EDGE_SERVICE_CRASHED: CodeInfo(Category.EDGE_SERVICE, Severity.ERROR,
|
|
67
|
+
"An edge container's main loop raised and exited."),
|
|
68
|
+
EDGE_SERVICE_RESTARTED: CodeInfo(Category.EDGE_SERVICE, Severity.INFO,
|
|
69
|
+
"An edge container started again after a crash."),
|
|
70
|
+
EDGE_ANOMALY_SPOOL_DROPPED: CodeInfo(Category.EDGE_SERVICE, Severity.WARNING,
|
|
71
|
+
"The anomaly spool hit its cap and dropped its oldest entries."),
|
|
72
|
+
EDGE_PROBE: CodeInfo(Category.OTHER, Severity.INFO,
|
|
73
|
+
"A round-trip probe sent by `anomaly check`; never notifies."),
|
|
74
|
+
API_JOB_FAILED: CodeInfo(Category.JOB, Severity.ERROR,
|
|
75
|
+
"A scheduled API job raised."),
|
|
76
|
+
API_AUDIT_WRITE_FAILED: CodeInfo(Category.DATABASE, Severity.WARNING,
|
|
77
|
+
"An audit log entry could not be written; the action itself succeeded."),
|
|
78
|
+
API_REQUEST_FAILED: CodeInfo(Category.API, Severity.ERROR,
|
|
79
|
+
"A request ended in a 5xx."),
|
|
80
|
+
API_CACHE_FAILED: CodeInfo(Category.INTEGRATION, Severity.INFO,
|
|
81
|
+
"A best-effort cache write failed."),
|
|
82
|
+
LAMBDA_REDIS_PUSH_FAILED: CodeInfo(Category.STREAM, Severity.CRITICAL,
|
|
83
|
+
"The S3 lambda could not publish an object to Redis; the event is not in the pipeline."),
|
|
84
|
+
LAMBDA_COPY_FAILED: CodeInfo(Category.INTEGRATION, Severity.ERROR,
|
|
85
|
+
"The S3 lambda could not copy an object to its destination bucket."),
|
|
86
|
+
ROUTER_API_UNREACHABLE: CodeInfo(Category.API, Severity.ERROR,
|
|
87
|
+
"The router exhausted its retries against the API."),
|
|
88
|
+
ROUTER_SITE_CAP_REFUSED: CodeInfo(Category.API, Severity.INFO,
|
|
89
|
+
"The API refused a new pending site because the org is at its cap."),
|
|
90
|
+
NOTIFIER_DELIVERY_EMAIL_FAILED: CodeInfo(Category.DELIVERY, Severity.WARNING,
|
|
91
|
+
"An e-mail notification could not be sent."),
|
|
92
|
+
NOTIFIER_DELIVERY_SMS_FAILED: CodeInfo(Category.DELIVERY, Severity.WARNING,
|
|
93
|
+
"An SMS notification could not be sent."),
|
|
94
|
+
NOTIFIER_SILENCE_CHECK_FAILED: CodeInfo(Category.JOB, Severity.ERROR,
|
|
95
|
+
"The site-silence check could not run."),
|
|
96
|
+
NOTIFIER_ANOMALY_RECORD_FAILED: CodeInfo(Category.STREAM, Severity.WARNING,
|
|
97
|
+
"The anomaly consumer could not record a message with the API."),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for _system in ("router", "indexer", "notifier", "tally", "describer"):
|
|
101
|
+
CATALOGUE[consumer_stopped(_system)] = CodeInfo(
|
|
102
|
+
Category.STREAM, Severity.CRITICAL, f"The {_system}'s consumer thread stopped; messages are not being read.")
|
|
103
|
+
CATALOGUE[message_failed(_system)] = CodeInfo(
|
|
104
|
+
Category.STREAM, Severity.ERROR, f"The {_system} failed to process one stream message.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def info(code: str) -> CodeInfo:
|
|
108
|
+
"""Catalogue entry for a code, or a generic one for an unlisted code."""
|
|
109
|
+
return CATALOGUE.get(code, CodeInfo(Category.OTHER, Severity.WARNING, "Unlisted code."))
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""The anomaly event contract.
|
|
2
|
+
|
|
3
|
+
One definition, imported by the API that records anomalies, the subscribers
|
|
4
|
+
that publish and consume them, and the edge processes that report them. A
|
|
5
|
+
change here bumps ANOMALY_SCHEMA_VERSION; the API accepts the current and the
|
|
6
|
+
previous version so a box on an older kit keeps reporting.
|
|
7
|
+
|
|
8
|
+
Bounds are enforced here so no producer can flood the stream or the table:
|
|
9
|
+
`details` is one level deep, at most DETAILS_MAX_KEYS keys, and string values
|
|
10
|
+
longer than DETAILS_MAX_VALUE_CHARS are truncated (a traceback tail is useful,
|
|
11
|
+
a whole log file is not).
|
|
12
|
+
"""
|
|
13
|
+
import re
|
|
14
|
+
import uuid
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Dict, Optional, Union
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
20
|
+
|
|
21
|
+
ANOMALY_SCHEMA_VERSION = 1
|
|
22
|
+
|
|
23
|
+
CODE_PATTERN = r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){1,3}$"
|
|
24
|
+
SLUG_PATTERN = r"^[a-z0-9][a-z0-9_-]*$"
|
|
25
|
+
SYSTEM_PATTERN = r"^[a-z][a-z0-9_-]*$"
|
|
26
|
+
|
|
27
|
+
MESSAGE_MAX_CHARS = 500
|
|
28
|
+
DETAILS_MAX_KEYS = 32
|
|
29
|
+
DETAILS_MAX_VALUE_CHARS = 1024
|
|
30
|
+
DEDUPE_KEY_MAX_CHARS = 256
|
|
31
|
+
TRUNCATION_MARK = "…"
|
|
32
|
+
|
|
33
|
+
DetailValue = Union[str, int, float, bool, None]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Severity(str, Enum):
|
|
37
|
+
INFO = "info"
|
|
38
|
+
WARNING = "warning"
|
|
39
|
+
ERROR = "error"
|
|
40
|
+
CRITICAL = "critical"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
SEVERITY_ORDER = {Severity.INFO: 0, Severity.WARNING: 1, Severity.ERROR: 2, Severity.CRITICAL: 3}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def severity_at_least(severity: Severity, floor: Severity) -> bool:
|
|
47
|
+
"""True when `severity` is as serious as `floor` or more."""
|
|
48
|
+
return SEVERITY_ORDER[Severity(severity)] >= SEVERITY_ORDER[Severity(floor)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Category(str, Enum):
|
|
52
|
+
UPLOAD = "upload"
|
|
53
|
+
STREAM = "stream"
|
|
54
|
+
API = "api"
|
|
55
|
+
DATABASE = "database"
|
|
56
|
+
DELIVERY = "delivery"
|
|
57
|
+
JOB = "job"
|
|
58
|
+
EDGE_SERVICE = "edge-service"
|
|
59
|
+
INTEGRATION = "integration"
|
|
60
|
+
OTHER = "other"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AnomalySource(BaseModel):
|
|
64
|
+
"""Who is reporting: the system (edge, api, router, ...), which instance of
|
|
65
|
+
it (a device id, an ECS task, a lambda request id) and its version."""
|
|
66
|
+
model_config = ConfigDict(extra="forbid")
|
|
67
|
+
|
|
68
|
+
system: str = Field(pattern=SYSTEM_PATTERN, max_length=32)
|
|
69
|
+
instance: Optional[str] = Field(default=None, max_length=128)
|
|
70
|
+
version: Optional[str] = Field(default=None, max_length=64)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AnomalyEvent(BaseModel):
|
|
74
|
+
model_config = ConfigDict(extra="forbid")
|
|
75
|
+
|
|
76
|
+
schema_version: int = ANOMALY_SCHEMA_VERSION
|
|
77
|
+
id: uuid.UUID = Field(default_factory=uuid.uuid4)
|
|
78
|
+
occurred_at: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc))
|
|
79
|
+
source: AnomalySource
|
|
80
|
+
org_slug: Optional[str] = Field(default=None, pattern=SLUG_PATTERN, max_length=64)
|
|
81
|
+
site_slug: Optional[str] = Field(default=None, pattern=SLUG_PATTERN, max_length=64)
|
|
82
|
+
category: Category
|
|
83
|
+
code: str = Field(pattern=CODE_PATTERN, max_length=96)
|
|
84
|
+
severity: Severity
|
|
85
|
+
message: str = Field(min_length=1, max_length=MESSAGE_MAX_CHARS)
|
|
86
|
+
details: Dict[str, DetailValue] = Field(default_factory=dict)
|
|
87
|
+
dedupe_key: Optional[str] = Field(default=None, max_length=DEDUPE_KEY_MAX_CHARS)
|
|
88
|
+
|
|
89
|
+
@field_validator("occurred_at")
|
|
90
|
+
@classmethod
|
|
91
|
+
def _occurred_at_utc(cls, value: datetime) -> datetime:
|
|
92
|
+
"""A naive timestamp is taken as UTC; an aware one is converted to it."""
|
|
93
|
+
if value.tzinfo is None:
|
|
94
|
+
return value.replace(tzinfo=timezone.utc)
|
|
95
|
+
return value.astimezone(timezone.utc)
|
|
96
|
+
|
|
97
|
+
@field_validator("message", mode="before")
|
|
98
|
+
@classmethod
|
|
99
|
+
def _message_stripped(cls, value):
|
|
100
|
+
if isinstance(value, str):
|
|
101
|
+
value = value.strip()
|
|
102
|
+
if len(value) > MESSAGE_MAX_CHARS:
|
|
103
|
+
return value[: MESSAGE_MAX_CHARS - len(TRUNCATION_MARK)] + TRUNCATION_MARK
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
@field_validator("details", mode="before")
|
|
107
|
+
@classmethod
|
|
108
|
+
def _details_bounded(cls, value):
|
|
109
|
+
if value is None:
|
|
110
|
+
return {}
|
|
111
|
+
if not isinstance(value, dict):
|
|
112
|
+
raise ValueError("details must be an object")
|
|
113
|
+
if len(value) > DETAILS_MAX_KEYS:
|
|
114
|
+
raise ValueError(f"details has {len(value)} keys; at most {DETAILS_MAX_KEYS} allowed")
|
|
115
|
+
bounded = {}
|
|
116
|
+
for key, item in value.items():
|
|
117
|
+
if not isinstance(key, str) or not key:
|
|
118
|
+
raise ValueError("details keys must be non-empty strings")
|
|
119
|
+
if isinstance(item, (dict, list, tuple, set)):
|
|
120
|
+
raise ValueError(f"details.{key} must be a scalar; nested values are not allowed")
|
|
121
|
+
if isinstance(item, str) and len(item) > DETAILS_MAX_VALUE_CHARS:
|
|
122
|
+
item = item[: DETAILS_MAX_VALUE_CHARS - len(TRUNCATION_MARK)] + TRUNCATION_MARK
|
|
123
|
+
bounded[key] = item
|
|
124
|
+
return bounded
|
|
125
|
+
|
|
126
|
+
@model_validator(mode="after")
|
|
127
|
+
def _default_dedupe_key(self):
|
|
128
|
+
if not self.dedupe_key:
|
|
129
|
+
self.dedupe_key = self.default_dedupe_key(self.source.system, self.code, self.site_slug)
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def default_dedupe_key(system: str, code: str, site_slug: Optional[str]) -> str:
|
|
134
|
+
return f"{system}:{code}:{site_slug or '-'}"
|
|
135
|
+
|
|
136
|
+
def with_site(self, site_slug: str, org_slug: Optional[str] = None) -> "AnomalyEvent":
|
|
137
|
+
"""A copy pinned to a site. A default dedupe key follows the site; an
|
|
138
|
+
explicit one is kept. The API uses this to pin a device's report to
|
|
139
|
+
the site its key authenticated."""
|
|
140
|
+
default_before = self.default_dedupe_key(self.source.system, self.code, self.site_slug)
|
|
141
|
+
dedupe_key = self.default_dedupe_key(self.source.system, self.code, site_slug) \
|
|
142
|
+
if self.dedupe_key == default_before else self.dedupe_key
|
|
143
|
+
return self.model_copy(update={
|
|
144
|
+
"site_slug": site_slug, "org_slug": org_slug or self.org_slug, "dedupe_key": dedupe_key,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def topic(self) -> str:
|
|
149
|
+
"""The stream topic: `{system}/{code}`, so a consumer can subscribe by either."""
|
|
150
|
+
return f"{self.source.system}/{self.code}"
|
|
151
|
+
|
|
152
|
+
def to_json(self) -> str:
|
|
153
|
+
return self.model_dump_json()
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def from_json(cls, payload: Union[str, bytes, dict]) -> "AnomalyEvent":
|
|
157
|
+
if isinstance(payload, dict):
|
|
158
|
+
return cls.model_validate(payload)
|
|
159
|
+
return cls.model_validate_json(payload)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def is_valid_code(code: str) -> bool:
|
|
163
|
+
return re.match(CODE_PATTERN, code or "") is not None
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zen-anomaly-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Anomaly event contract and client for the zen-space anomaly stream
|
|
5
|
+
Author-email: Hank Haines <hankhaines@wellchecked.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE.md
|
|
12
|
+
Requires-Dist: pydantic<3,>=2.5
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# zen-anomaly-client
|
|
18
|
+
|
|
19
|
+
The anomaly event contract for the zen-space platform, and (from Phase 3) the
|
|
20
|
+
client, wiring helpers and pytest harness an edge or service process uses to
|
|
21
|
+
report failures to the anomaly stream.
|
|
22
|
+
|
|
23
|
+
Plan and phases: `docs/ANOMALY_EVENT_STREAM_PLAN.md` in zen-space-network-api.
|
|
24
|
+
Edge contract (endpoints, codes, examples): `docs/contracts/ANOMALY_EDGE_CONTRACT.md`
|
|
25
|
+
in the same repository.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
pip install zen-anomaly-client
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Depends on `pydantic` only.
|
|
34
|
+
|
|
35
|
+
## The contract
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from zen_anomaly_client import AnomalyEvent, AnomalySource, Category, Severity, codes
|
|
39
|
+
|
|
40
|
+
event = AnomalyEvent(
|
|
41
|
+
source=AnomalySource(system="edge", instance="org1.box7", version="1.4.0"),
|
|
42
|
+
site_slug="gil",
|
|
43
|
+
category=Category.UPLOAD,
|
|
44
|
+
code=codes.EDGE_UPLOAD_FAILED,
|
|
45
|
+
severity=Severity.WARNING,
|
|
46
|
+
message="upload failed after 3 attempts",
|
|
47
|
+
details={"file": "w_Gil_camera0_20261112120001.mp4", "size": 12345, "error": "ReadTimeoutError"},
|
|
48
|
+
)
|
|
49
|
+
event.to_json() # the wire format
|
|
50
|
+
event.topic # "edge/edge.upload.failed"
|
|
51
|
+
event.dedupe_key # "edge:edge.upload.failed:gil" unless you set one
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Rules the model enforces: `code` is `system.subject.what` in lower snake case
|
|
55
|
+
(2–4 segments); `message` is stripped and cut at 500 characters; `details` is
|
|
56
|
+
one level deep, at most 32 keys, string values cut at 1024 characters; slugs
|
|
57
|
+
are lower-case; timestamps are normalised to UTC. Unknown fields are rejected.
|
|
58
|
+
|
|
59
|
+
`codes.CATALOGUE` lists every known code with its category, default severity
|
|
60
|
+
and meaning. Codes not in the catalogue are accepted but shown as "Unlisted".
|
|
61
|
+
|
|
62
|
+
## Development
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
python -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
|
66
|
+
.venv/bin/python -m pytest
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Release: `bin/pypi-build.sh` then `bin/pypi-upload.sh` (twine, PyPI credentials required).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE.md
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/zen_anomaly_client/__init__.py
|
|
5
|
+
src/zen_anomaly_client/codes.py
|
|
6
|
+
src/zen_anomaly_client/contract.py
|
|
7
|
+
src/zen_anomaly_client.egg-info/PKG-INFO
|
|
8
|
+
src/zen_anomaly_client.egg-info/SOURCES.txt
|
|
9
|
+
src/zen_anomaly_client.egg-info/dependency_links.txt
|
|
10
|
+
src/zen_anomaly_client.egg-info/requires.txt
|
|
11
|
+
src/zen_anomaly_client.egg-info/top_level.txt
|
|
12
|
+
tests/test_contract.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zen_anomaly_client
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import uuid
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
from zen_anomaly_client import (
|
|
9
|
+
ANOMALY_SCHEMA_VERSION, AnomalyEvent, AnomalySource, Category, Severity, codes, severity_at_least,
|
|
10
|
+
)
|
|
11
|
+
from zen_anomaly_client.contract import (
|
|
12
|
+
DEDUPE_KEY_MAX_CHARS, DETAILS_MAX_KEYS, DETAILS_MAX_VALUE_CHARS, MESSAGE_MAX_CHARS, TRUNCATION_MARK, is_valid_code,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _event(**overrides):
|
|
17
|
+
base = dict(
|
|
18
|
+
source=AnomalySource(system="edge", instance="org1.box7", version="1.4.0"),
|
|
19
|
+
site_slug="gil",
|
|
20
|
+
category=Category.UPLOAD,
|
|
21
|
+
code=codes.EDGE_UPLOAD_FAILED,
|
|
22
|
+
severity=Severity.WARNING,
|
|
23
|
+
message="upload failed",
|
|
24
|
+
details={"file": "w_Gil_camera0_20261112120001.mp4", "size": 12345, "attempt": 3},
|
|
25
|
+
)
|
|
26
|
+
base.update(overrides)
|
|
27
|
+
return AnomalyEvent(**base)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestRequiredAndDefaults:
|
|
31
|
+
|
|
32
|
+
def test_minimal_event_gets_id_timestamp_version_and_dedupe_key(self):
|
|
33
|
+
event = AnomalyEvent(source=AnomalySource(system="api"), category=Category.JOB,
|
|
34
|
+
code=codes.API_JOB_FAILED, severity=Severity.ERROR, message="job raised")
|
|
35
|
+
assert isinstance(event.id, uuid.UUID)
|
|
36
|
+
assert event.occurred_at.tzinfo is timezone.utc
|
|
37
|
+
assert event.schema_version == ANOMALY_SCHEMA_VERSION
|
|
38
|
+
assert event.dedupe_key == "api:api.job.failed:-"
|
|
39
|
+
assert event.details == {}
|
|
40
|
+
assert event.site_slug is None and event.org_slug is None
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize("missing", ["source", "category", "code", "severity", "message"])
|
|
43
|
+
def test_each_required_field_is_required(self, missing):
|
|
44
|
+
fields = dict(source=AnomalySource(system="api"), category=Category.JOB, code=codes.API_JOB_FAILED,
|
|
45
|
+
severity=Severity.ERROR, message="x")
|
|
46
|
+
del fields[missing]
|
|
47
|
+
with pytest.raises(ValidationError):
|
|
48
|
+
AnomalyEvent(**fields)
|
|
49
|
+
|
|
50
|
+
def test_unknown_fields_are_rejected(self):
|
|
51
|
+
with pytest.raises(ValidationError):
|
|
52
|
+
AnomalyEvent(source=AnomalySource(system="api"), category=Category.JOB, code=codes.API_JOB_FAILED,
|
|
53
|
+
severity=Severity.ERROR, message="x", extra_field=1)
|
|
54
|
+
|
|
55
|
+
def test_dedupe_key_includes_the_site_and_an_explicit_key_wins(self):
|
|
56
|
+
assert _event().dedupe_key == "edge:edge.upload.failed:gil"
|
|
57
|
+
assert _event(dedupe_key="custom").dedupe_key == "custom"
|
|
58
|
+
with pytest.raises(ValidationError):
|
|
59
|
+
_event(dedupe_key="k" * (DEDUPE_KEY_MAX_CHARS + 1))
|
|
60
|
+
|
|
61
|
+
def test_with_site_pins_slug_and_follows_default_dedupe_key(self):
|
|
62
|
+
unpinned = AnomalyEvent(source=AnomalySource(system="edge"), category=Category.UPLOAD,
|
|
63
|
+
code=codes.EDGE_UPLOAD_FAILED, severity=Severity.WARNING, message="x")
|
|
64
|
+
pinned = unpinned.with_site("gil", org_slug="acme")
|
|
65
|
+
assert (pinned.site_slug, pinned.org_slug) == ("gil", "acme")
|
|
66
|
+
assert pinned.dedupe_key == "edge:edge.upload.failed:gil"
|
|
67
|
+
assert pinned.id == unpinned.id and unpinned.site_slug is None
|
|
68
|
+
explicit = _event(dedupe_key="mine").with_site("other")
|
|
69
|
+
assert explicit.dedupe_key == "mine" and explicit.site_slug == "other"
|
|
70
|
+
repinned = _event().with_site("other")
|
|
71
|
+
assert repinned.dedupe_key == "edge:edge.upload.failed:other" and repinned.org_slug is None
|
|
72
|
+
|
|
73
|
+
def test_topic_is_system_slash_code(self):
|
|
74
|
+
assert _event().topic == "edge/edge.upload.failed"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestCodes:
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize("code", [
|
|
80
|
+
"edge.upload.failed", "api.job.failed", "router.consumer.stopped", "a.b", "lambda.redis.push_failed",
|
|
81
|
+
"x.y.z.w",
|
|
82
|
+
])
|
|
83
|
+
def test_valid_codes(self, code):
|
|
84
|
+
assert is_valid_code(code)
|
|
85
|
+
assert _event(code=code).code == code
|
|
86
|
+
|
|
87
|
+
@pytest.mark.parametrize("code", [
|
|
88
|
+
"edge", "Edge.upload", "edge..upload", ".edge.upload", "edge.upload.", "edge upload", "edge.Upload.failed",
|
|
89
|
+
"1edge.upload", "a.b.c.d.e", "",
|
|
90
|
+
])
|
|
91
|
+
def test_invalid_codes_are_rejected(self, code):
|
|
92
|
+
assert not is_valid_code(code)
|
|
93
|
+
with pytest.raises(ValidationError):
|
|
94
|
+
_event(code=code)
|
|
95
|
+
|
|
96
|
+
def test_every_catalogue_code_is_valid_and_has_info(self):
|
|
97
|
+
for code, entry in codes.CATALOGUE.items():
|
|
98
|
+
assert is_valid_code(code), code
|
|
99
|
+
assert isinstance(entry.category, Category)
|
|
100
|
+
assert isinstance(entry.severity, Severity)
|
|
101
|
+
assert entry.description
|
|
102
|
+
assert codes.consumer_stopped("router") in codes.CATALOGUE
|
|
103
|
+
assert codes.info("not.listed").description == "Unlisted code."
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TestEnumsAndSeverity:
|
|
107
|
+
|
|
108
|
+
def test_severity_and_category_accept_their_string_values(self):
|
|
109
|
+
event = _event(severity="critical", category="edge-service")
|
|
110
|
+
assert event.severity is Severity.CRITICAL
|
|
111
|
+
assert event.category is Category.EDGE_SERVICE
|
|
112
|
+
|
|
113
|
+
@pytest.mark.parametrize("value", ["fatal", "WARNING", "", None])
|
|
114
|
+
def test_bad_severity_rejected(self, value):
|
|
115
|
+
with pytest.raises(ValidationError):
|
|
116
|
+
_event(severity=value)
|
|
117
|
+
|
|
118
|
+
def test_bad_category_rejected(self):
|
|
119
|
+
with pytest.raises(ValidationError):
|
|
120
|
+
_event(category="video")
|
|
121
|
+
|
|
122
|
+
def test_severity_ordering(self):
|
|
123
|
+
assert severity_at_least(Severity.ERROR, Severity.ERROR)
|
|
124
|
+
assert severity_at_least(Severity.CRITICAL, Severity.ERROR)
|
|
125
|
+
assert not severity_at_least(Severity.WARNING, Severity.ERROR)
|
|
126
|
+
assert severity_at_least("warning", "info")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TestBounds:
|
|
130
|
+
|
|
131
|
+
def test_message_is_stripped_and_truncated_not_rejected(self):
|
|
132
|
+
assert _event(message=" spaced ").message == "spaced"
|
|
133
|
+
long = "m" * (MESSAGE_MAX_CHARS + 50)
|
|
134
|
+
truncated = _event(message=long).message
|
|
135
|
+
assert len(truncated) == MESSAGE_MAX_CHARS and truncated.endswith(TRUNCATION_MARK)
|
|
136
|
+
|
|
137
|
+
def test_empty_message_rejected(self):
|
|
138
|
+
with pytest.raises(ValidationError):
|
|
139
|
+
_event(message=" ")
|
|
140
|
+
|
|
141
|
+
def test_details_too_many_keys_rejected(self):
|
|
142
|
+
with pytest.raises(ValidationError):
|
|
143
|
+
_event(details={f"k{i}": i for i in range(DETAILS_MAX_KEYS + 1)})
|
|
144
|
+
assert len(_event(details={f"k{i}": i for i in range(DETAILS_MAX_KEYS)}).details) == DETAILS_MAX_KEYS
|
|
145
|
+
|
|
146
|
+
def test_details_nested_values_rejected(self):
|
|
147
|
+
with pytest.raises(ValidationError):
|
|
148
|
+
_event(details={"nested": {"a": 1}})
|
|
149
|
+
with pytest.raises(ValidationError):
|
|
150
|
+
_event(details={"list": [1, 2]})
|
|
151
|
+
|
|
152
|
+
def test_details_long_strings_truncated(self):
|
|
153
|
+
tail = "t" * (DETAILS_MAX_VALUE_CHARS * 3)
|
|
154
|
+
value = _event(details={"traceback": tail}).details["traceback"]
|
|
155
|
+
assert len(value) == DETAILS_MAX_VALUE_CHARS and value.endswith(TRUNCATION_MARK)
|
|
156
|
+
|
|
157
|
+
def test_details_scalars_kept_as_is(self):
|
|
158
|
+
details = _event(details={"n": 1, "f": 1.5, "b": True, "none": None, "s": "x"}).details
|
|
159
|
+
assert details == {"n": 1, "f": 1.5, "b": True, "none": None, "s": "x"}
|
|
160
|
+
|
|
161
|
+
def test_details_none_becomes_empty(self):
|
|
162
|
+
assert _event(details=None).details == {}
|
|
163
|
+
|
|
164
|
+
@pytest.mark.parametrize("slug", ["Gil", "gil pad", "-gil", ""])
|
|
165
|
+
def test_bad_slugs_rejected(self, slug):
|
|
166
|
+
with pytest.raises(ValidationError):
|
|
167
|
+
_event(site_slug=slug)
|
|
168
|
+
|
|
169
|
+
def test_bad_system_rejected(self):
|
|
170
|
+
with pytest.raises(ValidationError):
|
|
171
|
+
AnomalySource(system="Edge Box")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class TestTimestamps:
|
|
175
|
+
|
|
176
|
+
def test_naive_timestamp_is_taken_as_utc(self):
|
|
177
|
+
naive = datetime(2026, 11, 12, 12, 0, 1)
|
|
178
|
+
assert _event(occurred_at=naive).occurred_at == datetime(2026, 11, 12, 12, 0, 1, tzinfo=timezone.utc)
|
|
179
|
+
|
|
180
|
+
def test_aware_timestamp_is_converted_to_utc(self):
|
|
181
|
+
cst = timezone(timedelta(hours=-6))
|
|
182
|
+
event = _event(occurred_at=datetime(2026, 11, 12, 6, 0, 1, tzinfo=cst))
|
|
183
|
+
assert event.occurred_at == datetime(2026, 11, 12, 12, 0, 1, tzinfo=timezone.utc)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class TestRoundTrip:
|
|
187
|
+
|
|
188
|
+
def test_json_round_trip_is_lossless(self):
|
|
189
|
+
event = _event()
|
|
190
|
+
copy = AnomalyEvent.from_json(event.to_json())
|
|
191
|
+
assert copy == event
|
|
192
|
+
assert copy.id == event.id
|
|
193
|
+
|
|
194
|
+
def test_from_json_accepts_dict_and_bytes(self):
|
|
195
|
+
event = _event()
|
|
196
|
+
as_dict = json.loads(event.to_json())
|
|
197
|
+
assert AnomalyEvent.from_json(as_dict) == event
|
|
198
|
+
assert AnomalyEvent.from_json(event.to_json().encode()) == event
|
|
199
|
+
|
|
200
|
+
def test_json_has_the_wire_shape(self):
|
|
201
|
+
payload = json.loads(_event().to_json())
|
|
202
|
+
assert set(payload) == {"schema_version", "id", "occurred_at", "source", "org_slug", "site_slug", "category",
|
|
203
|
+
"code", "severity", "message", "details", "dedupe_key"}
|
|
204
|
+
assert payload["severity"] == "warning" and payload["category"] == "upload"
|
|
205
|
+
assert payload["occurred_at"].endswith("Z") or "+00:00" in payload["occurred_at"]
|