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.
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/CHANGELOG.md +6 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/PKG-INFO +58 -1
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/README.md +57 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/pyproject.toml +1 -1
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/__init__.py +3 -3
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/client.py +5 -3
- uptimer_python_sdk-1.6.0rc0/src/uptimer/endpoints/subjects.py +154 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/v2.py +3 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/v2/__init__.py +26 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/v2/deserialize.py +12 -0
- uptimer_python_sdk-1.6.0rc0/src/uptimer/models/v2/observation.py +71 -0
- uptimer_python_sdk-1.6.0rc0/tests/integrations/test_observations.py +143 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/test_model_namespace.py +13 -0
- uptimer_python_sdk-1.6.0rc0/tests/test_observations_endpoint.py +217 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/uv.lock +1 -1
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/.gitignore +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/.pre-commit-config.yaml +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/.vscode/extensions.json +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/.vscode/launch.json +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/.vscode/settings.json +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/.vscode/tasks.json +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/LICENSE +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/NOTICE +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/Taskfile.yml +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/01_client_setup.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/02_list_workspaces.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/03_list_locations.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/04_create_website_monitor.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/05_open_incidents.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/README.md +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/__init__.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/compat.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/__init__.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/endpoint.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/incidents.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/locations.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/websites.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/workspaces.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/errors.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/http.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/__init__.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/errors.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/v2/incident.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/v2/location.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/v2/monitor.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/v2/workspace.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/py.typed +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/__init__.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/conftest.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/integrations/__init__.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/integrations/conftest.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/integrations/test_workspaces.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/test_client.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/test_endpoint.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/test_integration_v2.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/test_v2_endpoints.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/test_workspaces_endpoint.py +0 -0
- {uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tools/mypy-diff.sh +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: uptimer-python-sdk
|
|
3
|
-
Version: 1.
|
|
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,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.
|
|
8
|
-
uptimer 1.
|
|
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.
|
|
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
|
|
16
|
-
`client.v2.monitoring.websites
|
|
17
|
-
|
|
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
|
]
|
{uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/models/v2/deserialize.py
RENAMED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/examples/04_create_website_monitor.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/src/uptimer/endpoints/workspaces.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{uptimer_python_sdk-1.5.0 → uptimer_python_sdk-1.6.0rc0}/tests/integrations/test_workspaces.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|