uptimer-python-sdk 1.5.0__tar.gz → 1.6.0rc0__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.
Files changed (58) hide show
  1. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/CHANGELOG.md +6 -0
  2. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/PKG-INFO +58 -1
  3. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/README.md +57 -0
  4. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/pyproject.toml +1 -1
  5. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/__init__.py +3 -3
  6. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/client.py +5 -3
  7. uptimer_python_sdk-1.6.0rc0/src/uptimer/endpoints/subjects.py +154 -0
  8. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/v2.py +3 -0
  9. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/v2/__init__.py +26 -0
  10. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/v2/deserialize.py +12 -0
  11. uptimer_python_sdk-1.6.0rc0/src/uptimer/models/v2/observation.py +71 -0
  12. uptimer_python_sdk-1.6.0rc0/tests/integrations/test_observations.py +143 -0
  13. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/test_model_namespace.py +13 -0
  14. uptimer_python_sdk-1.6.0rc0/tests/test_observations_endpoint.py +217 -0
  15. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/uv.lock +1 -1
  16. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/.gitignore +0 -0
  17. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/.pre-commit-config.yaml +0 -0
  18. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/.vscode/extensions.json +0 -0
  19. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/.vscode/launch.json +0 -0
  20. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/.vscode/settings.json +0 -0
  21. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/.vscode/tasks.json +0 -0
  22. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/LICENSE +0 -0
  23. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/NOTICE +0 -0
  24. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/Taskfile.yml +0 -0
  25. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/01_client_setup.py +0 -0
  26. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/02_list_workspaces.py +0 -0
  27. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/03_list_locations.py +0 -0
  28. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/04_create_website_monitor.py +0 -0
  29. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/05_open_incidents.py +0 -0
  30. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/README.md +0 -0
  31. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/__init__.py +0 -0
  32. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/compat.py +0 -0
  33. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/__init__.py +0 -0
  34. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/endpoint.py +0 -0
  35. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/incidents.py +0 -0
  36. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/locations.py +0 -0
  37. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/websites.py +0 -0
  38. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/workspaces.py +0 -0
  39. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/errors.py +0 -0
  40. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/http.py +0 -0
  41. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/__init__.py +0 -0
  42. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/errors.py +0 -0
  43. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/v2/incident.py +0 -0
  44. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/v2/location.py +0 -0
  45. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/v2/monitor.py +0 -0
  46. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/v2/workspace.py +0 -0
  47. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/py.typed +0 -0
  48. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/__init__.py +0 -0
  49. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/conftest.py +0 -0
  50. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/integrations/__init__.py +0 -0
  51. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/integrations/conftest.py +0 -0
  52. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/integrations/test_workspaces.py +0 -0
  53. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/test_client.py +0 -0
  54. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/test_endpoint.py +0 -0
  55. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/test_integration_v2.py +0 -0
  56. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/test_v2_endpoints.py +0 -0
  57. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/test_workspaces_endpoint.py +0 -0
  58. {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tools/mypy-diff.sh +0 -0
@@ -1,3 +1,9 @@
1
+ ## 1.6.0-rc.0 (2026-08-30)
2
+
3
+ ### Feat
4
+
5
+ - **api**: post custom observations via client.v2 subjects/signals
6
+
1
7
  ## 1.5.0 (2026-08-20)
2
8
 
3
9
  ### BREAKING CHANGE
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: uptimer-python-sdk
3
- Version: 1.5.0
3
+ Version: 1.6.0rc0
4
4
  Summary: A Python SDK for hosted and self-hosted Uptimer
5
5
  Project-URL: Homepage, https://myuptime.info
6
6
  Project-URL: Documentation, https://uptimer.myuptime.info/latest/reference/python-sdk/
@@ -153,6 +153,63 @@ except UptimerError: # base error, if you need one
153
153
  raise
154
154
  ```
155
155
 
156
+ ### Reporting your own observations
157
+
158
+ Uptimer probes websites itself. For anything else — a cron job, a queue worker,
159
+ a nightly export — you add a **custom signal** to a subject in the Uptimer UI
160
+ and report to it yourself.
161
+
162
+ Requires Uptimer 1.6.0 or later, and a **custom heartbeat or event** signal. The
163
+ platform HTTP signal of a website monitor is written by Uptimer's own probe and
164
+ refuses posted observations.
165
+
166
+ ```python
167
+ from uptimer.client import UptimerClient
168
+ from uptimer.models.v2 import (
169
+ OBSERVATION_STATUS_OK,
170
+ OBSERVATION_STATUS_PROBLEM,
171
+ CreateObservationRequest,
172
+ )
173
+
174
+ client = UptimerClient(
175
+ api_key="your-api-key-here",
176
+ base_url="http://127.0.0.1:2517/api",
177
+ )
178
+
179
+ # The two slugs are the address: the subject, and the signal within it. Both
180
+ # are shown on the signal's page in the Uptimer UI.
181
+ observations = client.v2.subjects("checkout-api").signals("worker-pulse").observations
182
+
183
+ # A heartbeat: "I ran, and I am fine."
184
+ stored = observations.create(CreateObservationRequest(status=OBSERVATION_STATUS_OK))
185
+
186
+ # Everything except status is optional.
187
+ stored = observations.create(
188
+ CreateObservationRequest(
189
+ status=OBSERVATION_STATUS_PROBLEM,
190
+ observed_at="2026-08-30T12:00:00Z", # RFC 3339; omit to mean "now"
191
+ value=0.0, # optional numeric reading
192
+ error="queue backlog over threshold",
193
+ labels={"instance": "worker-3", "env": "prod"},
194
+ ),
195
+ )
196
+
197
+ print(stored.accepted, stored.reject_reason)
198
+ ```
199
+
200
+ `accepted` reports **acceptance, not health**: it says Uptimer stored the
201
+ observation and may evaluate it, not that anything is wrong or fine. Whether an
202
+ observation raises an incident is decided by a *rule* that selects the signal.
203
+
204
+ An observation Uptimer keeps but will not evaluate — one stamped too far in the
205
+ future, say — comes back with `accepted=False` and a `reject_reason` such as
206
+ `clock_skew`. It is **returned, not raised**: it was received. An exception
207
+ means nothing was stored.
208
+
209
+ Retries are safe. An observation is identified by its signal, its `observed_at`
210
+ and its labels, so re-sending the same one replaces it rather than counting
211
+ twice.
212
+
156
213
  ### Incident status
157
214
 
158
215
  `client.v2.incidents.all()` returns only **open** incidents. `status` carries the
@@ -137,6 +137,63 @@ except UptimerError: # base error, if you need one
137
137
  raise
138
138
  ```
139
139
 
140
+ ### Reporting your own observations
141
+
142
+ Uptimer probes websites itself. For anything else — a cron job, a queue worker,
143
+ a nightly export — you add a **custom signal** to a subject in the Uptimer UI
144
+ and report to it yourself.
145
+
146
+ Requires Uptimer 1.6.0 or later, and a **custom heartbeat or event** signal. The
147
+ platform HTTP signal of a website monitor is written by Uptimer's own probe and
148
+ refuses posted observations.
149
+
150
+ ```python
151
+ from uptimer.client import UptimerClient
152
+ from uptimer.models.v2 import (
153
+ OBSERVATION_STATUS_OK,
154
+ OBSERVATION_STATUS_PROBLEM,
155
+ CreateObservationRequest,
156
+ )
157
+
158
+ client = UptimerClient(
159
+ api_key="your-api-key-here",
160
+ base_url="http://127.0.0.1:2517/api",
161
+ )
162
+
163
+ # The two slugs are the address: the subject, and the signal within it. Both
164
+ # are shown on the signal's page in the Uptimer UI.
165
+ observations = client.v2.subjects("checkout-api").signals("worker-pulse").observations
166
+
167
+ # A heartbeat: "I ran, and I am fine."
168
+ stored = observations.create(CreateObservationRequest(status=OBSERVATION_STATUS_OK))
169
+
170
+ # Everything except status is optional.
171
+ stored = observations.create(
172
+ CreateObservationRequest(
173
+ status=OBSERVATION_STATUS_PROBLEM,
174
+ observed_at="2026-08-30T12:00:00Z", # RFC 3339; omit to mean "now"
175
+ value=0.0, # optional numeric reading
176
+ error="queue backlog over threshold",
177
+ labels={"instance": "worker-3", "env": "prod"},
178
+ ),
179
+ )
180
+
181
+ print(stored.accepted, stored.reject_reason)
182
+ ```
183
+
184
+ `accepted` reports **acceptance, not health**: it says Uptimer stored the
185
+ observation and may evaluate it, not that anything is wrong or fine. Whether an
186
+ observation raises an incident is decided by a *rule* that selects the signal.
187
+
188
+ An observation Uptimer keeps but will not evaluate — one stamped too far in the
189
+ future, say — comes back with `accepted=False` and a `reject_reason` such as
190
+ `clock_skew`. It is **returned, not raised**: it was received. An exception
191
+ means nothing was stored.
192
+
193
+ Retries are safe. An observation is identified by its signal, its `observed_at`
194
+ and its labels, so re-sending the same one replaces it rather than counting
195
+ twice.
196
+
140
197
  ### Incident status
141
198
 
142
199
  `client.v2.incidents.all()` returns only **open** incidents. `status` carries the
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "uptimer-python-sdk"
7
- version = "1.5.0"
7
+ version = "1.6.0-rc.0"
8
8
  description = "A Python SDK for hosted and self-hosted Uptimer"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -4,9 +4,9 @@ Uptimer Python SDK.
4
4
  Targets Uptimer API v2 only. Code written against 0.4.x keeps working against
5
5
  the server — API v1 is unchanged and supported — but must stay on the 0.4.x SDK.
6
6
 
7
- The version tracks the uptimer release this SDK targets: 1.5.x speaks to
8
- uptimer 1.5.0 and later. Patch numbers are independent, so an SDK fix can ship
7
+ The version tracks the uptimer release this SDK targets: 1.6.x speaks to
8
+ uptimer 1.6.0 and later. Patch numbers are independent, so an SDK fix can ship
9
9
  without a server release. See product Decision 0013.
10
10
  """
11
11
 
12
- __version__ = "1.5.0"
12
+ __version__ = "1.6.0-rc.0"
@@ -12,9 +12,11 @@ class UptimerClient:
12
12
  The Uptimer API client.
13
13
 
14
14
  Resources are reached through the API version that serves them:
15
- `client.v2.workspaces`, `client.v2.locations`, `client.v2.incidents` and
16
- `client.v2.monitoring.websites`. This SDK does not speak API v1 — see the
17
- migration note in the README if you are coming from 0.4.x.
15
+ `client.v2.workspaces`, `client.v2.locations`, `client.v2.incidents`,
16
+ `client.v2.monitoring.websites` and
17
+ `client.v2.subjects(subject).signals(signal).observations`. This SDK does
18
+ not speak API v1 — see the migration note in the README if you are coming
19
+ from 0.4.x.
18
20
 
19
21
  `version()` and the compatibility helpers stay here rather than under a
20
22
  version namespace, because `/version` is shared and unversioned.
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict
4
+ from typing import TYPE_CHECKING
5
+ from urllib.parse import quote
6
+
7
+ from uptimer.endpoints.endpoint import BaseEndpoint
8
+ from uptimer.models.v2 import from_api_observation
9
+
10
+ if TYPE_CHECKING:
11
+ from uptimer.http import UptimerHttpLib
12
+ from uptimer.models.v2 import CreateObservationRequest, Observation
13
+
14
+
15
+ def _slug(value: str, what: str) -> str:
16
+ """
17
+ Escape one slug for a URL path segment.
18
+
19
+ A slug is the API name of a subject or signal, and it lands in the path. It
20
+ is quoted rather than trusted so a value containing a slash addresses a
21
+ signal named that, instead of silently reaching a different resource.
22
+ """
23
+ if not value or not value.strip():
24
+ message = f"{what} slug is required"
25
+ raise ValueError(message)
26
+ return quote(value, safe="")
27
+
28
+
29
+ def _payload(observation: CreateObservationRequest) -> dict:
30
+ """
31
+ Serialize an observation, omitting what was not set.
32
+
33
+ The server rejects unknown fields and distinguishes an absent optional from
34
+ a null one — an absent `observed_at` means "stamp it now", where a null
35
+ would be a malformed timestamp. So unset fields are left out rather than
36
+ sent as None.
37
+ """
38
+ body = asdict(observation)
39
+ return {key: value for key, value in body.items() if value is not None}
40
+
41
+
42
+ class ObservationsEndpoint(BaseEndpoint):
43
+ """
44
+ The observations of one signal.
45
+
46
+ Reached through the signal that owns them —
47
+ `client.v2.subjects(subject).signals(signal).observations` — because a
48
+ signal slug is unique within its subject, not across the workspace. The pair
49
+ is the address.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ http: UptimerHttpLib,
55
+ parent_segments: str | list[str] | None = None,
56
+ ):
57
+ super().__init__(http, "observations", parent_segments)
58
+
59
+ def create(self, observation: CreateObservationRequest) -> Observation:
60
+ """
61
+ Report one observation, and return it as the server stored it.
62
+
63
+ Only custom heartbeat and event signals accept this. A platform HTTP
64
+ signal is written by Uptimer's own probe and refuses posted
65
+ observations, which arrives as a DefaultUptimerApiError.
66
+
67
+ A stored observation the engine will not evaluate is RETURNED, not
68
+ raised: check `accepted` and `reject_reason` on the result. An error is
69
+ raised only when nothing was stored — a bad status, an unparsable
70
+ timestamp, a signal that does not exist, or no permission.
71
+ """
72
+ response = self.http.client.post(self.url, json=_payload(observation))
73
+ result = self.http.parse_response(response=response)
74
+ return from_api_observation(result)
75
+
76
+
77
+ class SignalEndpoint(BaseEndpoint):
78
+ """One signal of a subject, addressed by its slug."""
79
+
80
+ observations: ObservationsEndpoint
81
+
82
+ def __init__(
83
+ self,
84
+ http: UptimerHttpLib,
85
+ signal_slug: str,
86
+ parent_segments: str | list[str] | None = None,
87
+ ):
88
+ super().__init__(http, _slug(signal_slug, "signal"), parent_segments)
89
+ self.observations = ObservationsEndpoint(
90
+ http,
91
+ [*self._parent_segments, self.segment],
92
+ )
93
+
94
+
95
+ class SignalsEndpoint(BaseEndpoint):
96
+ """
97
+ The signals of one subject.
98
+
99
+ Call it with a slug to reach one:
100
+ `client.v2.subjects("checkout").signals("worker-pulse")`.
101
+
102
+ There is no listing or authoring here. Signals are created and managed in
103
+ the Uptimer UI; the SDK exists to report data to one that already exists.
104
+ """
105
+
106
+ def __init__(
107
+ self,
108
+ http: UptimerHttpLib,
109
+ parent_segments: str | list[str] | None = None,
110
+ ):
111
+ super().__init__(http, "signals", parent_segments)
112
+
113
+ def __call__(self, signal_slug: str) -> SignalEndpoint:
114
+ return SignalEndpoint(self.http, signal_slug, self._parent_segments_with_self())
115
+
116
+ def _parent_segments_with_self(self) -> list[str]:
117
+ return [*self._parent_segments, self.segment]
118
+
119
+
120
+ class SubjectEndpoint(BaseEndpoint):
121
+ """One monitored subject, addressed by its slug."""
122
+
123
+ signals: SignalsEndpoint
124
+
125
+ def __init__(
126
+ self,
127
+ http: UptimerHttpLib,
128
+ subject_slug: str,
129
+ parent_segments: str | list[str] | None = None,
130
+ ):
131
+ super().__init__(http, _slug(subject_slug, "subject"), parent_segments)
132
+ self.signals = SignalsEndpoint(http, [*self._parent_segments, self.segment])
133
+
134
+
135
+ class SubjectsEndpoint(BaseEndpoint):
136
+ """
137
+ The monitored subjects.
138
+
139
+ Call it with a slug to reach one: `client.v2.subjects("checkout")`.
140
+
141
+ Deliberately narrow: there is no subject CRUD here. The one thing this
142
+ namespace exists for is reporting observations to a custom signal, and the
143
+ path to that is the subject that owns it.
144
+ """
145
+
146
+ def __init__(
147
+ self,
148
+ http: UptimerHttpLib,
149
+ parent_segments: str | list[str] | None = None,
150
+ ):
151
+ super().__init__(http, "subjects", parent_segments)
152
+
153
+ def __call__(self, subject_slug: str) -> SubjectEndpoint:
154
+ return SubjectEndpoint(self.http, subject_slug, [*self._parent_segments, self.segment])
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
5
5
  from uptimer.endpoints.endpoint import BaseEndpoint
6
6
  from uptimer.endpoints.incidents import IncidentsEndpoint
7
7
  from uptimer.endpoints.locations import LocationsEndpoint
8
+ from uptimer.endpoints.subjects import SubjectsEndpoint
8
9
  from uptimer.endpoints.websites import MonitoringEndpoint
9
10
  from uptimer.endpoints.workspaces import WorkspacesEndpoint
10
11
 
@@ -28,6 +29,7 @@ class V2Endpoint(BaseEndpoint):
28
29
  locations: LocationsEndpoint
29
30
  incidents: IncidentsEndpoint
30
31
  monitoring: MonitoringEndpoint
32
+ subjects: SubjectsEndpoint
31
33
 
32
34
  def __init__(self, http: UptimerHttpLib):
33
35
  super().__init__(http, "v2")
@@ -36,3 +38,4 @@ class V2Endpoint(BaseEndpoint):
36
38
  self.locations = LocationsEndpoint(http, parent)
37
39
  self.incidents = IncidentsEndpoint(http, parent)
38
40
  self.monitoring = MonitoringEndpoint(http, parent)
41
+ self.subjects = SubjectsEndpoint(http, parent)
@@ -13,6 +13,7 @@ from .deserialize import (
13
13
  from_api,
14
14
  from_api_incident,
15
15
  from_api_location,
16
+ from_api_observation,
16
17
  from_api_website_monitor,
17
18
  from_api_workspace,
18
19
  )
@@ -39,23 +40,47 @@ from .monitor import (
39
40
  WebsiteMonitorResponse,
40
41
  WebsiteMonitorResponseBody,
41
42
  )
43
+ from .observation import (
44
+ REJECT_ACCEPTED,
45
+ REJECT_CLOCK_SKEW,
46
+ REJECT_LATE,
47
+ REJECT_OUT_OF_ORDER,
48
+ REJECT_OUT_OF_RETENTION,
49
+ CreateObservationRequest,
50
+ Observation,
51
+ )
52
+ from .observation import (
53
+ STATUS_OK as OBSERVATION_STATUS_OK,
54
+ )
55
+ from .observation import (
56
+ STATUS_PROBLEM as OBSERVATION_STATUS_PROBLEM,
57
+ )
42
58
  from .workspace import Workspace
43
59
 
44
60
  __all__ = [
45
61
  "AGREEMENT_ALL",
46
62
  "AGREEMENT_ANY",
47
63
  "AGREEMENT_MAJORITY",
64
+ "OBSERVATION_STATUS_OK",
65
+ "OBSERVATION_STATUS_PROBLEM",
66
+ "REJECT_ACCEPTED",
67
+ "REJECT_CLOCK_SKEW",
68
+ "REJECT_LATE",
69
+ "REJECT_OUT_OF_ORDER",
70
+ "REJECT_OUT_OF_RETENTION",
48
71
  "STATUS_NO_DATA",
49
72
  "STATUS_OK",
50
73
  "STATUS_PENDING",
51
74
  "STATUS_PROBLEM",
52
75
  "STATUS_RECOVERING",
53
76
  "BaseWebsiteMonitor",
77
+ "CreateObservationRequest",
54
78
  "CreateWebsiteMonitorRequest",
55
79
  "DeleteWebsiteMonitorResponse",
56
80
  "Incident",
57
81
  "IncidentLocations",
58
82
  "Location",
83
+ "Observation",
59
84
  "UpdateWebsiteMonitorRequest",
60
85
  "WebsiteMonitor",
61
86
  "WebsiteMonitorRequest",
@@ -65,6 +90,7 @@ __all__ = [
65
90
  "from_api",
66
91
  "from_api_incident",
67
92
  "from_api_location",
93
+ "from_api_observation",
68
94
  "from_api_website_monitor",
69
95
  "from_api_workspace",
70
96
  ]
@@ -17,6 +17,7 @@ from .monitor import (
17
17
  WebsiteMonitorResponse,
18
18
  WebsiteMonitorResponseBody,
19
19
  )
20
+ from .observation import Observation
20
21
  from .workspace import Workspace
21
22
 
22
23
  T = TypeVar("T")
@@ -28,6 +29,7 @@ DeserializableType = Union[
28
29
  WebsiteMonitorResponseBody,
29
30
  Incident,
30
31
  Location,
32
+ Observation,
31
33
  Workspace,
32
34
  ]
33
35
 
@@ -42,6 +44,7 @@ _KIND_REGISTRY = {
42
44
  "website_monitor_response_body": WebsiteMonitorResponseBody,
43
45
  "incident": Incident,
44
46
  "location": Location,
47
+ "observation": Observation,
45
48
  "workspace": Workspace,
46
49
  }
47
50
 
@@ -117,3 +120,12 @@ def from_api_incident(data: dict[str, Any]) -> Incident:
117
120
  expected = "Incident"
118
121
  raise TypeMismatchError(expected, type(obj).__name__)
119
122
  return obj
123
+
124
+
125
+ def from_api_observation(data: dict[str, Any]) -> Observation:
126
+ """Deserialize a stored observation."""
127
+ obj = from_api(data)
128
+ if not isinstance(obj, Observation):
129
+ expected = "Observation"
130
+ raise TypeMismatchError(expected, type(obj).__name__)
131
+ return obj
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ # The two words a custom sender may report. There is no "unknown" to send: an
6
+ # observation exists to say whether the sender is ok or in trouble, and an unset
7
+ # status is never read as health.
8
+ STATUS_OK = "ok"
9
+ STATUS_PROBLEM = "problem"
10
+
11
+ # Why a stored observation will not be evaluated. `accepted` is the only one that
12
+ # means the engine may use the row; the rest are kept and shown so an operator
13
+ # can see that data arrived and was set aside, rather than wondering whether it
14
+ # was ever received.
15
+ REJECT_ACCEPTED = "accepted"
16
+ REJECT_LATE = "late"
17
+ REJECT_OUT_OF_ORDER = "out_of_order"
18
+ # Stamped too far ahead of the server's clock.
19
+ REJECT_CLOCK_SKEW = "clock_skew"
20
+ REJECT_OUT_OF_RETENTION = "out_of_retention"
21
+
22
+
23
+ @dataclass
24
+ class CreateObservationRequest:
25
+ """
26
+ One observation to report for a custom heartbeat or event signal.
27
+
28
+ Only `status` is required. `observed_at` defaults to the moment the server
29
+ receives the report, which is what a heartbeat usually wants; send it
30
+ explicitly when reporting something that happened earlier.
31
+
32
+ `labels` is your own vocabulary — Uptimer stores it and matches rules
33
+ against it, and never requires a particular key.
34
+ """
35
+
36
+ status: str
37
+ # RFC 3339, for example "2026-08-30T12:00:00Z". None means "now".
38
+ observed_at: str | None = None
39
+ # The optional numeric reading a rule can compare against a threshold.
40
+ value: float | None = None
41
+ # The sender's own error text, for a problem worth explaining.
42
+ error: str | None = None
43
+ labels: dict[str, str] | None = None
44
+
45
+
46
+ @dataclass
47
+ class Observation:
48
+ """
49
+ One observation as the server stored it.
50
+
51
+ `accepted` reports ACCEPTANCE, not health: it says the engine may use this
52
+ row, not that the subject is fine. Whether anything is wrong is decided by a
53
+ rule, and no rule need select this signal at all. Read `status` for what was
54
+ reported.
55
+
56
+ A refused observation is returned rather than raised as an error — it was
57
+ stored, it is visible in the Unaccepted log, and `reject_reason` names why
58
+ it will not be evaluated.
59
+ """
60
+
61
+ subject_id: str # the subject slug the observation was posted to
62
+ signal_id: str # the signal slug within that subject
63
+ observed_at: str # when the SENDER observed it
64
+ received_at: str # when the server stored it
65
+ status: str
66
+ value: float | None
67
+ error: str
68
+ labels: dict[str, str] = field(default_factory=dict)
69
+ accepted: bool = False
70
+ reject_reason: str = REJECT_ACCEPTED
71
+ kind: str = "observation"
@@ -0,0 +1,143 @@
1
+ """
2
+ Custom observation ingest against a running Uptimer.
3
+
4
+ Unlike the other integration tests, this one does not drive the browser to mint
5
+ a key: reporting an observation needs a subject and a signal that already exist,
6
+ so the run supplies them. Set
7
+
8
+ UPTIMER_API_KEY, UPTIMER_SUBJECT_SLUG, UPTIMER_SIGNAL_SLUG
9
+
10
+ and optionally UPTIMER_HTTP_SIGNAL_SLUG (a platform HTTP signal, to prove it is
11
+ refused) and UPTIMER_URL. Without the first three the module skips, so a plain
12
+ `--integration` run against a server with no custom signal stays green.
13
+ """
14
+
15
+ import os
16
+ from datetime import datetime, timedelta, timezone
17
+
18
+ import pytest
19
+
20
+ from tests.conftest import integration_test
21
+ from uptimer.errors import DefaultUptimerApiError
22
+ from uptimer.models.v2 import (
23
+ OBSERVATION_STATUS_OK,
24
+ OBSERVATION_STATUS_PROBLEM,
25
+ REJECT_ACCEPTED,
26
+ REJECT_CLOCK_SKEW,
27
+ CreateObservationRequest,
28
+ )
29
+
30
+ from .conftest import get_client
31
+
32
+ API_KEY = os.environ.get("UPTIMER_API_KEY", "")
33
+ SUBJECT_SLUG = os.environ.get("UPTIMER_SUBJECT_SLUG", "")
34
+ SIGNAL_SLUG = os.environ.get("UPTIMER_SIGNAL_SLUG", "")
35
+ HTTP_SIGNAL_SLUG = os.environ.get("UPTIMER_HTTP_SIGNAL_SLUG", "")
36
+
37
+ needs_signal = pytest.mark.skipif(
38
+ not (API_KEY and SUBJECT_SLUG and SIGNAL_SLUG),
39
+ reason="set UPTIMER_API_KEY, UPTIMER_SUBJECT_SLUG and UPTIMER_SIGNAL_SLUG",
40
+ )
41
+
42
+
43
+ def _observations(uptimer_url: str): # noqa: ANN202
44
+ client = get_client(API_KEY, uptimer_url)
45
+ return client.v2.subjects(SUBJECT_SLUG).signals(SIGNAL_SLUG).observations
46
+
47
+
48
+ def _at(offset: timedelta = timedelta()) -> str:
49
+ stamped = datetime.now(timezone.utc).replace(microsecond=0) + offset
50
+ return stamped.isoformat().replace("+00:00", "Z")
51
+
52
+
53
+ @integration_test
54
+ @needs_signal
55
+ def test_report_an_accepted_observation(uptimer_url: str):
56
+ stored = _observations(uptimer_url).create(
57
+ CreateObservationRequest(
58
+ status=OBSERVATION_STATUS_OK,
59
+ observed_at=_at(),
60
+ value=1.5,
61
+ labels={"instance": "sdk-it"},
62
+ ),
63
+ )
64
+
65
+ assert stored.kind == "observation"
66
+ assert stored.subject_id == SUBJECT_SLUG
67
+ assert stored.signal_id == SIGNAL_SLUG
68
+ assert stored.status == OBSERVATION_STATUS_OK
69
+ assert stored.value == 1.5
70
+ assert stored.labels == {"instance": "sdk-it"}
71
+ assert stored.accepted is True
72
+ assert stored.reject_reason == REJECT_ACCEPTED
73
+ assert stored.received_at
74
+
75
+
76
+ @integration_test
77
+ @needs_signal
78
+ def test_status_only_is_enough(uptimer_url: str):
79
+ """Everything but status is optional; the server stamps the time."""
80
+ stored = _observations(uptimer_url).create(
81
+ CreateObservationRequest(status=OBSERVATION_STATUS_PROBLEM),
82
+ )
83
+
84
+ assert stored.accepted is True
85
+ assert stored.status == OBSERVATION_STATUS_PROBLEM
86
+ assert stored.observed_at
87
+
88
+
89
+ @integration_test
90
+ @needs_signal
91
+ def test_a_skewed_observation_is_stored_and_not_accepted(uptimer_url: str):
92
+ """
93
+ Stored, not raised.
94
+
95
+ A timestamp far ahead of the server is kept and shown, never evaluated. The
96
+ sender is told which of those two happened.
97
+ """
98
+ stored = _observations(uptimer_url).create(
99
+ CreateObservationRequest(
100
+ status=OBSERVATION_STATUS_OK,
101
+ observed_at=_at(timedelta(hours=1)),
102
+ labels={"instance": "sdk-it-skew"},
103
+ ),
104
+ )
105
+
106
+ assert stored.accepted is False
107
+ assert stored.reject_reason == REJECT_CLOCK_SKEW
108
+
109
+
110
+ @integration_test
111
+ @needs_signal
112
+ def test_an_invalid_status_is_refused(uptimer_url: str):
113
+ with pytest.raises(DefaultUptimerApiError) as excinfo:
114
+ _observations(uptimer_url).create(CreateObservationRequest(status="degraded"))
115
+ assert "status" in excinfo.value.message.lower()
116
+
117
+
118
+ @integration_test
119
+ @needs_signal
120
+ def test_an_unknown_signal_is_refused(uptimer_url: str):
121
+ client = get_client(API_KEY, uptimer_url)
122
+ observations = client.v2.subjects(SUBJECT_SLUG).signals("no-such-signal").observations
123
+
124
+ with pytest.raises(DefaultUptimerApiError) as excinfo:
125
+ observations.create(CreateObservationRequest(status=OBSERVATION_STATUS_OK))
126
+ assert excinfo.value.error_type == "not_found"
127
+
128
+
129
+ @integration_test
130
+ @pytest.mark.skipif(
131
+ not (API_KEY and SUBJECT_SLUG and HTTP_SIGNAL_SLUG),
132
+ reason="set UPTIMER_HTTP_SIGNAL_SLUG to prove the platform signal is refused",
133
+ )
134
+ def test_a_platform_http_signal_is_refused(uptimer_url: str):
135
+ """Uptimer's own probe owns that stream; a posted claim is not a measurement."""
136
+ client = get_client(API_KEY, uptimer_url)
137
+ observations = (
138
+ client.v2.subjects(SUBJECT_SLUG).signals(HTTP_SIGNAL_SLUG).observations
139
+ )
140
+
141
+ with pytest.raises(DefaultUptimerApiError) as excinfo:
142
+ observations.create(CreateObservationRequest(status=OBSERVATION_STATUS_OK))
143
+ assert excinfo.value.error_type == "forbidden"
@@ -37,17 +37,29 @@ def test_v2_exports_the_whole_public_surface():
37
37
  "AGREEMENT_ALL",
38
38
  "AGREEMENT_ANY",
39
39
  "AGREEMENT_MAJORITY",
40
+ # The observation status words are prefixed: an incident STATUS_OK is a
41
+ # derived display state, an observation's is what a sender reported.
42
+ # Same string, different question — so they do not share a name.
43
+ "OBSERVATION_STATUS_OK",
44
+ "OBSERVATION_STATUS_PROBLEM",
45
+ "REJECT_ACCEPTED",
46
+ "REJECT_CLOCK_SKEW",
47
+ "REJECT_LATE",
48
+ "REJECT_OUT_OF_ORDER",
49
+ "REJECT_OUT_OF_RETENTION",
40
50
  "STATUS_NO_DATA",
41
51
  "STATUS_OK",
42
52
  "STATUS_PENDING",
43
53
  "STATUS_PROBLEM",
44
54
  "STATUS_RECOVERING",
45
55
  "BaseWebsiteMonitor",
56
+ "CreateObservationRequest",
46
57
  "CreateWebsiteMonitorRequest",
47
58
  "DeleteWebsiteMonitorResponse",
48
59
  "Incident",
49
60
  "IncidentLocations",
50
61
  "Location",
62
+ "Observation",
51
63
  "UpdateWebsiteMonitorRequest",
52
64
  "WebsiteMonitor",
53
65
  "WebsiteMonitorRequest",
@@ -57,6 +69,7 @@ def test_v2_exports_the_whole_public_surface():
57
69
  "from_api",
58
70
  "from_api_incident",
59
71
  "from_api_location",
72
+ "from_api_observation",
60
73
  "from_api_website_monitor",
61
74
  "from_api_workspace",
62
75
  }
@@ -0,0 +1,217 @@
1
+ """
2
+ Custom observation ingest: `client.v2.subjects(s).signals(g).observations`.
3
+
4
+ The path is the contract as much as the payload — a signal slug is unique
5
+ within its subject, not across the workspace, so the pair is the address and
6
+ both halves must reach the URL intact.
7
+
8
+ The other half of the contract is what is NOT an error: a stored observation
9
+ the engine will not evaluate comes back with `accepted: False`, because it was
10
+ received. Only a request that stored nothing raises.
11
+ """
12
+
13
+ import json
14
+
15
+ import pytest
16
+ from pytest_httpx import HTTPXMock
17
+
18
+ from tests.conftest import api_response
19
+ from uptimer.client import UptimerClient
20
+ from uptimer.errors import DefaultUptimerApiError
21
+ from uptimer.models.v2 import (
22
+ OBSERVATION_STATUS_OK,
23
+ OBSERVATION_STATUS_PROBLEM,
24
+ REJECT_ACCEPTED,
25
+ REJECT_CLOCK_SKEW,
26
+ CreateObservationRequest,
27
+ Observation,
28
+ from_api_observation,
29
+ )
30
+
31
+
32
+ def _stored(
33
+ *,
34
+ accepted: bool = True,
35
+ reject_reason: str = REJECT_ACCEPTED,
36
+ status: str = OBSERVATION_STATUS_OK,
37
+ ) -> dict:
38
+ return {
39
+ "subject_id": "checkout",
40
+ "signal_id": "worker-pulse",
41
+ "observed_at": "2026-08-30T12:00:00Z",
42
+ "received_at": "2026-08-30T12:00:01Z",
43
+ "status": status,
44
+ "value": 1.5,
45
+ "error": "",
46
+ "labels": {"instance": "i-123"},
47
+ "accepted": accepted,
48
+ "reject_reason": reject_reason,
49
+ "kind": "observation",
50
+ }
51
+
52
+
53
+ def test_paths(uptimer_client: UptimerClient):
54
+ assert uptimer_client.v2.subjects.path == "v2/subjects"
55
+ subject = uptimer_client.v2.subjects("checkout")
56
+ assert subject.signals.path == "v2/subjects/checkout/signals"
57
+ assert (
58
+ subject.signals("worker-pulse").observations.path
59
+ == "v2/subjects/checkout/signals/worker-pulse/observations"
60
+ )
61
+
62
+
63
+ def test_slugs_are_escaped_into_the_path(uptimer_client: UptimerClient):
64
+ """A slug is data, not a path fragment: a slash must not address elsewhere."""
65
+ observations = uptimer_client.v2.subjects("a/b").signals("c d").observations
66
+ assert observations.path == "v2/subjects/a%2Fb/signals/c%20d/observations"
67
+
68
+
69
+ @pytest.mark.parametrize("slug", ["", " "])
70
+ def test_an_empty_slug_is_refused(uptimer_client: UptimerClient, slug: str):
71
+ with pytest.raises(ValueError, match="slug is required"):
72
+ uptimer_client.v2.subjects(slug)
73
+ with pytest.raises(ValueError, match="slug is required"):
74
+ uptimer_client.v2.subjects("checkout").signals(slug)
75
+
76
+
77
+ def test_create_posts_to_the_signals_observations_url(
78
+ uptimer_client: UptimerClient,
79
+ httpx_mock: HTTPXMock,
80
+ ):
81
+ httpx_mock.add_response(json=api_response(_stored()))
82
+
83
+ uptimer_client.v2.subjects("checkout").signals("worker-pulse").observations.create(
84
+ CreateObservationRequest(status=OBSERVATION_STATUS_OK),
85
+ )
86
+
87
+ request = httpx_mock.get_requests()[0]
88
+ assert request.method == "POST"
89
+ assert str(request.url).endswith(
90
+ "/v2/subjects/checkout/signals/worker-pulse/observations",
91
+ )
92
+
93
+
94
+ def test_create_sends_only_the_fields_that_were_set(
95
+ uptimer_client: UptimerClient,
96
+ httpx_mock: HTTPXMock,
97
+ ):
98
+ """
99
+ Only what was set is sent.
100
+
101
+ The server rejects unknown fields and reads an absent optional differently
102
+ from a null one — an absent observed_at means "stamp it now".
103
+ """
104
+ httpx_mock.add_response(json=api_response(_stored()))
105
+
106
+ uptimer_client.v2.subjects("checkout").signals("worker-pulse").observations.create(
107
+ CreateObservationRequest(status=OBSERVATION_STATUS_OK),
108
+ )
109
+
110
+ body = json.loads(httpx_mock.get_requests()[0].content)
111
+ assert body == {"status": "ok"}
112
+
113
+
114
+ def test_create_sends_every_field_that_was_set(
115
+ uptimer_client: UptimerClient,
116
+ httpx_mock: HTTPXMock,
117
+ ):
118
+ httpx_mock.add_response(json=api_response(_stored()))
119
+
120
+ uptimer_client.v2.subjects("checkout").signals("worker-pulse").observations.create(
121
+ CreateObservationRequest(
122
+ status=OBSERVATION_STATUS_PROBLEM,
123
+ observed_at="2026-08-30T12:00:00Z",
124
+ value=0.0,
125
+ error="connection refused",
126
+ labels={"instance": "i-123"},
127
+ ),
128
+ )
129
+
130
+ body = json.loads(httpx_mock.get_requests()[0].content)
131
+ assert body == {
132
+ "status": "problem",
133
+ "observed_at": "2026-08-30T12:00:00Z",
134
+ "value": 0.0,
135
+ "error": "connection refused",
136
+ "labels": {"instance": "i-123"},
137
+ }
138
+
139
+
140
+ def test_create_returns_the_stored_observation(
141
+ uptimer_client: UptimerClient,
142
+ httpx_mock: HTTPXMock,
143
+ ):
144
+ httpx_mock.add_response(json=api_response(_stored()))
145
+
146
+ stored = uptimer_client.v2.subjects("checkout").signals(
147
+ "worker-pulse",
148
+ ).observations.create(CreateObservationRequest(status=OBSERVATION_STATUS_OK))
149
+
150
+ assert isinstance(stored, Observation)
151
+ assert stored.subject_id == "checkout"
152
+ assert stored.signal_id == "worker-pulse"
153
+ assert stored.status == OBSERVATION_STATUS_OK
154
+ assert stored.value == 1.5
155
+ assert stored.labels == {"instance": "i-123"}
156
+ assert stored.accepted is True
157
+ assert stored.reject_reason == REJECT_ACCEPTED
158
+ assert stored.kind == "observation"
159
+
160
+
161
+ def test_a_refused_observation_is_returned_not_raised(
162
+ uptimer_client: UptimerClient,
163
+ httpx_mock: HTTPXMock,
164
+ ):
165
+ """
166
+ A refused observation is a result, not an error.
167
+
168
+ It was received and stored; the engine just will not evaluate it. Raising
169
+ here would tell a sender its data never arrived, which is the opposite of
170
+ what happened.
171
+ """
172
+ httpx_mock.add_response(
173
+ json=api_response(
174
+ _stored(
175
+ accepted=False,
176
+ reject_reason=REJECT_CLOCK_SKEW,
177
+ status=OBSERVATION_STATUS_PROBLEM,
178
+ ),
179
+ ),
180
+ )
181
+
182
+ stored = uptimer_client.v2.subjects("checkout").signals(
183
+ "worker-pulse",
184
+ ).observations.create(CreateObservationRequest(status=OBSERVATION_STATUS_PROBLEM))
185
+
186
+ assert stored.accepted is False
187
+ assert stored.reject_reason == REJECT_CLOCK_SKEW
188
+
189
+
190
+ def test_an_api_error_is_raised(
191
+ uptimer_client: UptimerClient,
192
+ httpx_mock: HTTPXMock,
193
+ ):
194
+ """A platform HTTP signal refuses posted observations, and so does a bad body."""
195
+ httpx_mock.add_response(
196
+ json=api_response(
197
+ None,
198
+ error={
199
+ "code": 2003,
200
+ "error_type": "forbidden",
201
+ "message": "Signal does not accept posted observations",
202
+ "details": "only custom heartbeat and custom event signals accept posted observations",
203
+ },
204
+ ),
205
+ )
206
+
207
+ with pytest.raises(DefaultUptimerApiError):
208
+ uptimer_client.v2.subjects("checkout").signals(
209
+ "website-http",
210
+ ).observations.create(CreateObservationRequest(status=OBSERVATION_STATUS_OK))
211
+
212
+
213
+ def test_observation_kind_is_registered():
214
+ """Without the kind, from_api cannot build this object at all."""
215
+ stored = from_api_observation(_stored())
216
+ assert isinstance(stored, Observation)
217
+ assert stored.accepted is True
@@ -1150,7 +1150,7 @@ wheels = [
1150
1150
 
1151
1151
  [[package]]
1152
1152
  name = "uptimer-python-sdk"
1153
- version = "1.5.0"
1153
+ version = "1.6.0-rc.0"
1154
1154
  source = { editable = "." }
1155
1155
  dependencies = [
1156
1156
  { name = "httpx" },