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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+ pydantic<3,>=2.5
2
+
3
+ [dev]
4
+ pytest>=7
@@ -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"]