ct-obs-app 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.
- ct_obs_app-0.1.0/.github/workflows/publish.yml +37 -0
- ct_obs_app-0.1.0/.gitignore +8 -0
- ct_obs_app-0.1.0/LICENSE +21 -0
- ct_obs_app-0.1.0/PKG-INFO +104 -0
- ct_obs_app-0.1.0/README.md +83 -0
- ct_obs_app-0.1.0/pyproject.toml +40 -0
- ct_obs_app-0.1.0/src/obs_app_sdk/__init__.py +16 -0
- ct_obs_app-0.1.0/src/obs_app_sdk/client.py +153 -0
- ct_obs_app-0.1.0/src/obs_app_sdk/exceptions.py +26 -0
- ct_obs_app-0.1.0/src/obs_app_sdk/logging_handler.py +46 -0
- ct_obs_app-0.1.0/src/obs_app_sdk/py.typed +0 -0
- ct_obs_app-0.1.0/src/obs_app_sdk/tracing.py +61 -0
- ct_obs_app-0.1.0/tests/__init__.py +0 -0
- ct_obs_app-0.1.0/tests/conftest.py +29 -0
- ct_obs_app-0.1.0/tests/test_client.py +163 -0
- ct_obs_app-0.1.0/tests/test_logging_handler.py +58 -0
- ct_obs_app-0.1.0/tests/test_tracing.py +62 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.x"
|
|
19
|
+
- run: pip install build
|
|
20
|
+
- run: python -m build
|
|
21
|
+
- uses: actions/upload-artifact@v4
|
|
22
|
+
with:
|
|
23
|
+
name: dist
|
|
24
|
+
path: dist/
|
|
25
|
+
|
|
26
|
+
publish:
|
|
27
|
+
needs: build
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
environment: pypi
|
|
30
|
+
permissions:
|
|
31
|
+
id-token: write # required for PyPI trusted publishing (OIDC) - no token/secret needed
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/download-artifact@v4
|
|
34
|
+
with:
|
|
35
|
+
name: dist
|
|
36
|
+
path: dist/
|
|
37
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
ct_obs_app-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Conor Turner
|
|
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,104 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ct_obs_app
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the obs-app.ctsoftware.co.uk (obs_app) ingestion API - logs, metrics and traces.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cturner91/obs-app-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/cturner91/obs-app-sdk
|
|
7
|
+
Author-email: Conor Turner <conor_turner@hotmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: System :: Logging
|
|
15
|
+
Classifier: Topic :: System :: Monitoring
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Requires-Dist: requests>=2.25
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# obs-app-sdk
|
|
23
|
+
|
|
24
|
+
Python client for the [obs-app.ctsoftware.co.uk](https://obs-app.ctsoftware.co.uk) ingestion API - send logs,
|
|
25
|
+
metrics and traces from your application.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install ct_obs_app
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
(the PyPI project is named `ct_obs_app`; the importable module is `obs_app_sdk` - see Quickstart below)
|
|
34
|
+
|
|
35
|
+
## Get an API key
|
|
36
|
+
|
|
37
|
+
Log in at the obs_app dashboard and copy your API key from the profile page (rotate it there
|
|
38
|
+
too, if needed). The SDK authenticates every request with it.
|
|
39
|
+
|
|
40
|
+
## Quickstart
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from obs_app_sdk import Client, CaptureTrace
|
|
44
|
+
|
|
45
|
+
client = Client(api_key="your-api-key")
|
|
46
|
+
|
|
47
|
+
# Logging
|
|
48
|
+
client.log("Something happened", level=4) # 1=Critical .. 5=Debug, default 4=Info
|
|
49
|
+
client.log_batch([{"message": "a"}, {"message": "b"}])
|
|
50
|
+
|
|
51
|
+
# Metrics
|
|
52
|
+
client.add_metric(value=1.0, metric="request_duration_ms", auto_create=True)
|
|
53
|
+
client.add_metrics([
|
|
54
|
+
{"metric": "request_duration_ms", "value": 12.3},
|
|
55
|
+
{"metric": "queue_depth", "value": 4},
|
|
56
|
+
], auto_create=True)
|
|
57
|
+
|
|
58
|
+
# Traces - nested context managers build one tree, sent as a single request when the
|
|
59
|
+
# outermost one exits
|
|
60
|
+
with CaptureTrace(client, "handle_request"):
|
|
61
|
+
with CaptureTrace(client, "query_db"):
|
|
62
|
+
...
|
|
63
|
+
with CaptureTrace(client, "render_response"):
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
# Or submit logs/metrics/traces together in one request (validated all-or-nothing)
|
|
67
|
+
client.ingest(
|
|
68
|
+
logs={"logs": [{"message": "started"}]},
|
|
69
|
+
metrics={"metrics": [{"metric": "request_duration_ms", "value": 12.3}]},
|
|
70
|
+
traces=[{"start": "...", "end": "...", "text": "handled"}],
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Stdlib logging integration
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import logging
|
|
78
|
+
from obs_app_sdk import Client, ObsAppLogHandler
|
|
79
|
+
|
|
80
|
+
client = Client(api_key="your-api-key")
|
|
81
|
+
logging.getLogger().addHandler(ObsAppLogHandler(client))
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Each stdlib log record becomes one `POST /api/log/` call. For high-volume logging, call
|
|
85
|
+
`client.log_batch(...)` directly instead of relying on the handler.
|
|
86
|
+
|
|
87
|
+
## Error handling
|
|
88
|
+
|
|
89
|
+
All methods raise on non-2xx responses:
|
|
90
|
+
|
|
91
|
+
- `ObsAppApiKeyError` - missing/invalid API key (HTTP 401)
|
|
92
|
+
- `ObsAppValidationError` - invalid request data (HTTP 400); `.errors` holds the server's
|
|
93
|
+
per-field error dict when there is one, else `None`
|
|
94
|
+
- `ObsAppRateLimitedError` - HTTP 429. The API blocks the offending IP for **1 hour** once its
|
|
95
|
+
rolling rate-limit window trips - do not retry in a loop on this error, it won't help
|
|
96
|
+
- `ObsAppError` - base class; also raised for network failures and unexpected status codes
|
|
97
|
+
|
|
98
|
+
## Notes
|
|
99
|
+
|
|
100
|
+
- This client is synchronous and makes one HTTP request per call - there is no background
|
|
101
|
+
buffering/auto-flush thread. Use the batch methods (`log_batch`, `add_metrics`, `ingest`)
|
|
102
|
+
to reduce request count for high-volume callers.
|
|
103
|
+
- `add_metrics` (batch) only supports metric names, not `metric_id` - use `add_metric`
|
|
104
|
+
(singular) for id-based lookups.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# obs-app-sdk
|
|
2
|
+
|
|
3
|
+
Python client for the [obs-app.ctsoftware.co.uk](https://obs-app.ctsoftware.co.uk) ingestion API - send logs,
|
|
4
|
+
metrics and traces from your application.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install ct_obs_app
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
(the PyPI project is named `ct_obs_app`; the importable module is `obs_app_sdk` - see Quickstart below)
|
|
13
|
+
|
|
14
|
+
## Get an API key
|
|
15
|
+
|
|
16
|
+
Log in at the obs_app dashboard and copy your API key from the profile page (rotate it there
|
|
17
|
+
too, if needed). The SDK authenticates every request with it.
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from obs_app_sdk import Client, CaptureTrace
|
|
23
|
+
|
|
24
|
+
client = Client(api_key="your-api-key")
|
|
25
|
+
|
|
26
|
+
# Logging
|
|
27
|
+
client.log("Something happened", level=4) # 1=Critical .. 5=Debug, default 4=Info
|
|
28
|
+
client.log_batch([{"message": "a"}, {"message": "b"}])
|
|
29
|
+
|
|
30
|
+
# Metrics
|
|
31
|
+
client.add_metric(value=1.0, metric="request_duration_ms", auto_create=True)
|
|
32
|
+
client.add_metrics([
|
|
33
|
+
{"metric": "request_duration_ms", "value": 12.3},
|
|
34
|
+
{"metric": "queue_depth", "value": 4},
|
|
35
|
+
], auto_create=True)
|
|
36
|
+
|
|
37
|
+
# Traces - nested context managers build one tree, sent as a single request when the
|
|
38
|
+
# outermost one exits
|
|
39
|
+
with CaptureTrace(client, "handle_request"):
|
|
40
|
+
with CaptureTrace(client, "query_db"):
|
|
41
|
+
...
|
|
42
|
+
with CaptureTrace(client, "render_response"):
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
# Or submit logs/metrics/traces together in one request (validated all-or-nothing)
|
|
46
|
+
client.ingest(
|
|
47
|
+
logs={"logs": [{"message": "started"}]},
|
|
48
|
+
metrics={"metrics": [{"metric": "request_duration_ms", "value": 12.3}]},
|
|
49
|
+
traces=[{"start": "...", "end": "...", "text": "handled"}],
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Stdlib logging integration
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import logging
|
|
57
|
+
from obs_app_sdk import Client, ObsAppLogHandler
|
|
58
|
+
|
|
59
|
+
client = Client(api_key="your-api-key")
|
|
60
|
+
logging.getLogger().addHandler(ObsAppLogHandler(client))
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Each stdlib log record becomes one `POST /api/log/` call. For high-volume logging, call
|
|
64
|
+
`client.log_batch(...)` directly instead of relying on the handler.
|
|
65
|
+
|
|
66
|
+
## Error handling
|
|
67
|
+
|
|
68
|
+
All methods raise on non-2xx responses:
|
|
69
|
+
|
|
70
|
+
- `ObsAppApiKeyError` - missing/invalid API key (HTTP 401)
|
|
71
|
+
- `ObsAppValidationError` - invalid request data (HTTP 400); `.errors` holds the server's
|
|
72
|
+
per-field error dict when there is one, else `None`
|
|
73
|
+
- `ObsAppRateLimitedError` - HTTP 429. The API blocks the offending IP for **1 hour** once its
|
|
74
|
+
rolling rate-limit window trips - do not retry in a loop on this error, it won't help
|
|
75
|
+
- `ObsAppError` - base class; also raised for network failures and unexpected status codes
|
|
76
|
+
|
|
77
|
+
## Notes
|
|
78
|
+
|
|
79
|
+
- This client is synchronous and makes one HTTP request per call - there is no background
|
|
80
|
+
buffering/auto-flush thread. Use the batch methods (`log_batch`, `add_metrics`, `ingest`)
|
|
81
|
+
to reduce request count for high-volume callers.
|
|
82
|
+
- `add_metrics` (batch) only supports metric names, not `metric_id` - use `add_metric`
|
|
83
|
+
(singular) for id-based lookups.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ct_obs_app"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the obs-app.ctsoftware.co.uk (obs_app) ingestion API - logs, metrics and traces."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Conor Turner", email = "conor_turner@hotmail.com" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Topic :: System :: Logging",
|
|
21
|
+
"Topic :: System :: Monitoring",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"requests>=2.25",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/cturner91/obs-app-sdk"
|
|
29
|
+
Repository = "https://github.com/cturner91/obs-app-sdk"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
test = [
|
|
33
|
+
"pytest>=7.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/obs_app_sdk"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from obs_app_sdk.client import Client
|
|
2
|
+
from obs_app_sdk.exceptions import ObsAppApiKeyError, ObsAppError, ObsAppRateLimitedError, ObsAppValidationError
|
|
3
|
+
from obs_app_sdk.logging_handler import ObsAppLogHandler
|
|
4
|
+
from obs_app_sdk.tracing import CaptureTrace
|
|
5
|
+
|
|
6
|
+
__version__ = '0.1.0'
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
'Client',
|
|
10
|
+
'CaptureTrace',
|
|
11
|
+
'ObsAppLogHandler',
|
|
12
|
+
'ObsAppError',
|
|
13
|
+
'ObsAppApiKeyError',
|
|
14
|
+
'ObsAppValidationError',
|
|
15
|
+
'ObsAppRateLimitedError',
|
|
16
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from obs_app_sdk.exceptions import ObsAppApiKeyError, ObsAppError, ObsAppRateLimitedError, ObsAppValidationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Client:
|
|
12
|
+
'''Thin synchronous wrapper around the obs-app.ctsoftware.co.uk ingestion API. Every method makes
|
|
13
|
+
one HTTP call - there's no background buffering/auto-flush, so batch calls (log_batch,
|
|
14
|
+
add_metrics, ingest) are the way to reduce request count for high-volume callers.'''
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
api_key: str,
|
|
19
|
+
base_url: str = 'https://obs-app.ctsoftware.co.uk',
|
|
20
|
+
timeout: float = 5.0,
|
|
21
|
+
session: requests.Session | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.api_key = api_key
|
|
24
|
+
self.base_url = base_url.rstrip('/')
|
|
25
|
+
self.timeout = timeout
|
|
26
|
+
self.session = session or requests.Session()
|
|
27
|
+
|
|
28
|
+
def _post(self, path: str, payload: dict) -> dict:
|
|
29
|
+
try:
|
|
30
|
+
response = self.session.post(
|
|
31
|
+
f'{self.base_url}{path}',
|
|
32
|
+
json=payload,
|
|
33
|
+
headers={'Authorization': f'Bearer {self.api_key}'},
|
|
34
|
+
timeout=self.timeout,
|
|
35
|
+
)
|
|
36
|
+
except requests.RequestException as exc:
|
|
37
|
+
raise ObsAppError(f'Request to {path} failed: {exc}') from exc
|
|
38
|
+
|
|
39
|
+
if response.status_code == 429:
|
|
40
|
+
# the server blocks the offending IP for 1 hour once the rolling rate-limit window
|
|
41
|
+
# trips - the block response isn't guaranteed to be JSON (some paths render an HTML
|
|
42
|
+
# template instead), so parse defensively. Retrying immediately will not help.
|
|
43
|
+
try:
|
|
44
|
+
message = response.json().get('error', 'Rate limited')
|
|
45
|
+
except ValueError:
|
|
46
|
+
message = 'Rate limited - IP is blocked for up to 1 hour, do not retry immediately'
|
|
47
|
+
raise ObsAppRateLimitedError(message)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
body = response.json()
|
|
51
|
+
except ValueError:
|
|
52
|
+
body = {}
|
|
53
|
+
|
|
54
|
+
if response.status_code == 401:
|
|
55
|
+
raise ObsAppApiKeyError(body.get('error', 'Invalid or missing API key'))
|
|
56
|
+
|
|
57
|
+
if response.status_code == 400:
|
|
58
|
+
raise ObsAppValidationError(body.get('error', 'Invalid data'), errors=body.get('errors'))
|
|
59
|
+
|
|
60
|
+
if not response.ok:
|
|
61
|
+
raise ObsAppError(f'{path} returned HTTP {response.status_code}: {body}')
|
|
62
|
+
|
|
63
|
+
return body
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def _iso(value: datetime | str | None) -> str | None:
|
|
67
|
+
if value is None or isinstance(value, str):
|
|
68
|
+
return value
|
|
69
|
+
return value.isoformat()
|
|
70
|
+
|
|
71
|
+
def log(self, message: str, level: int = 4, request_id: str | None = None) -> str:
|
|
72
|
+
'''POST /api/log/ - level: 1=Critical, 2=Error, 3=Warning, 4=Info (default), 5=Debug.
|
|
73
|
+
Returns the server-assigned log id.'''
|
|
74
|
+
payload: dict[str, Any] = {'message': message, 'level': level}
|
|
75
|
+
if request_id is not None:
|
|
76
|
+
payload['request_id'] = request_id
|
|
77
|
+
return self._post('/api/log/', payload)['id']
|
|
78
|
+
|
|
79
|
+
def log_batch(self, logs: list[dict], request_id: str | None = None) -> None:
|
|
80
|
+
'''POST /api/logs/ - each entry in logs is {'message': str, 'level'?: int, 'request_id'?: str}.
|
|
81
|
+
request_id (if given) applies to any entry that doesn't specify its own.'''
|
|
82
|
+
payload: dict[str, Any] = {'logs': logs}
|
|
83
|
+
if request_id is not None:
|
|
84
|
+
payload['request_id'] = request_id
|
|
85
|
+
self._post('/api/logs/', payload)
|
|
86
|
+
|
|
87
|
+
def add_metric(
|
|
88
|
+
self,
|
|
89
|
+
value: float,
|
|
90
|
+
metric: str | None = None,
|
|
91
|
+
metric_id: str | None = None,
|
|
92
|
+
timestamp: datetime | str | None = None,
|
|
93
|
+
auto_create: bool = False,
|
|
94
|
+
) -> None:
|
|
95
|
+
'''POST /api/metric/ - provide exactly one of metric (name) or metric_id.'''
|
|
96
|
+
if not metric and not metric_id:
|
|
97
|
+
raise ValueError('Either metric or metric_id is required')
|
|
98
|
+
if metric and metric_id:
|
|
99
|
+
raise ValueError('Provide only one of metric or metric_id')
|
|
100
|
+
|
|
101
|
+
payload: dict[str, Any] = {'value': value, 'auto_create': auto_create}
|
|
102
|
+
if metric:
|
|
103
|
+
payload['metric'] = metric
|
|
104
|
+
if metric_id:
|
|
105
|
+
payload['metric_id'] = metric_id
|
|
106
|
+
iso_timestamp = self._iso(timestamp)
|
|
107
|
+
if iso_timestamp is not None:
|
|
108
|
+
payload['timestamp'] = iso_timestamp
|
|
109
|
+
|
|
110
|
+
self._post('/api/metric/', payload)
|
|
111
|
+
|
|
112
|
+
def add_metrics(self, metrics: list[dict], auto_create: bool = False) -> None:
|
|
113
|
+
'''POST /api/metrics/ - each entry is {'metric': str, 'value': float, 'timestamp'?: ...}.
|
|
114
|
+
Note: unlike add_metric, the server does not support metric_id-based lookup in a batch -
|
|
115
|
+
every entry must use 'metric' (name).'''
|
|
116
|
+
normalized = []
|
|
117
|
+
for entry in metrics:
|
|
118
|
+
entry = dict(entry)
|
|
119
|
+
if 'timestamp' in entry:
|
|
120
|
+
entry['timestamp'] = self._iso(entry['timestamp'])
|
|
121
|
+
normalized.append(entry)
|
|
122
|
+
|
|
123
|
+
payload = {'metrics': normalized, 'auto_create': auto_create}
|
|
124
|
+
self._post('/api/metrics/', payload)
|
|
125
|
+
|
|
126
|
+
def send_trace(self, trace: dict) -> None:
|
|
127
|
+
'''POST /api/trace/ - trace is a single tree: {'start': ..., 'end': ..., 'text'?: str,
|
|
128
|
+
'traces'?: [nested trace trees]}. Prefer CaptureTrace over calling this directly.'''
|
|
129
|
+
self._post('/api/trace/', trace)
|
|
130
|
+
|
|
131
|
+
def ingest(
|
|
132
|
+
self,
|
|
133
|
+
traces: list[dict] | None = None,
|
|
134
|
+
logs: dict | None = None,
|
|
135
|
+
metrics: dict | None = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
'''POST /api/ingest/ - submit traces/logs/metrics in a single request, validated
|
|
138
|
+
all-or-nothing by the server (if any section is invalid, nothing is processed).
|
|
139
|
+
traces is a *list* of trace trees (unlike send_trace, which takes one).
|
|
140
|
+
logs is {'logs': [...], 'request_id'?: str} (same shape as log_batch's payload).
|
|
141
|
+
metrics is {'metrics': [...], 'auto_create'?: bool} (same shape as add_metrics' payload).'''
|
|
142
|
+
if traces is None and logs is None and metrics is None:
|
|
143
|
+
raise ValueError('At least one of traces, logs, or metrics is required')
|
|
144
|
+
|
|
145
|
+
payload: dict[str, Any] = {}
|
|
146
|
+
if traces is not None:
|
|
147
|
+
payload['traces'] = traces
|
|
148
|
+
if logs is not None:
|
|
149
|
+
payload['logs'] = logs
|
|
150
|
+
if metrics is not None:
|
|
151
|
+
payload['metrics'] = metrics
|
|
152
|
+
|
|
153
|
+
self._post('/api/ingest/', payload)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class ObsAppError(Exception):
|
|
2
|
+
'''Base class for all errors raised by this SDK.'''
|
|
3
|
+
|
|
4
|
+
def __init__(self, message: str) -> None:
|
|
5
|
+
self.message = message
|
|
6
|
+
super().__init__(message)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ObsAppApiKeyError(ObsAppError):
|
|
10
|
+
'''Raised on a 401 response - the API key is missing or invalid.'''
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ObsAppValidationError(ObsAppError):
|
|
14
|
+
'''Raised on a 400 response. `errors` holds the per-field error dict when the server
|
|
15
|
+
returned one (schema validation failures); it's None for single-message 400s (e.g.
|
|
16
|
+
'Invalid metrics: ...').'''
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, errors: dict | None = None) -> None:
|
|
19
|
+
self.errors = errors
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ObsAppRateLimitedError(ObsAppError):
|
|
24
|
+
'''Raised on a 429 response. The server enforces a rolling-window rate limit and blocks
|
|
25
|
+
the offending IP for 1 hour once tripped - do not retry immediately, it will not help and
|
|
26
|
+
only keeps the block window extending.'''
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from obs_app_sdk.client import Client
|
|
8
|
+
|
|
9
|
+
# obs_app's own level scale (projects/obs_app/models.py): 1=Critical, 2=Error, 3=Warning,
|
|
10
|
+
# 4=Info, 5=Debug - the reverse of Python's, where higher stdlib levelno means more severe.
|
|
11
|
+
_STDLIB_TO_OBS_APP_LEVEL = {
|
|
12
|
+
logging.CRITICAL: 1,
|
|
13
|
+
logging.ERROR: 2,
|
|
14
|
+
logging.WARNING: 3,
|
|
15
|
+
logging.INFO: 4,
|
|
16
|
+
logging.DEBUG: 5,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _map_level(levelno: int) -> int:
|
|
21
|
+
for stdlib_level in sorted(_STDLIB_TO_OBS_APP_LEVEL, reverse=True):
|
|
22
|
+
if levelno >= stdlib_level:
|
|
23
|
+
return _STDLIB_TO_OBS_APP_LEVEL[stdlib_level]
|
|
24
|
+
return 5 # anything below DEBUG maps to Debug
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ObsAppLogHandler(logging.Handler):
|
|
28
|
+
'''logging.Handler that forwards records to obs-app.ctsoftware.co.uk via Client.log(). Attach it to
|
|
29
|
+
any stdlib logger to pipe that logger's output to the obs_app dashboard:
|
|
30
|
+
|
|
31
|
+
handler = ObsAppLogHandler(client)
|
|
32
|
+
logging.getLogger().addHandler(handler)
|
|
33
|
+
|
|
34
|
+
Each record is sent as its own HTTP request - for high-volume logging, log directly via
|
|
35
|
+
Client.log_batch() instead rather than relying on this handler.
|
|
36
|
+
'''
|
|
37
|
+
|
|
38
|
+
def __init__(self, client: 'Client') -> None:
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.client = client
|
|
41
|
+
|
|
42
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
43
|
+
try:
|
|
44
|
+
self.client.log(message=self.format(record), level=_map_level(record.levelno))
|
|
45
|
+
except Exception:
|
|
46
|
+
self.handleError(record)
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextvars
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from obs_app_sdk.client import Client
|
|
9
|
+
|
|
10
|
+
# thread/async-safe nesting - matches the server-side semantics of the internal
|
|
11
|
+
# obs_app.utils.CaptureTrace: only the root span, on exit, flushes the whole assembled tree.
|
|
12
|
+
_current: contextvars.ContextVar['CaptureTrace | None'] = contextvars.ContextVar('_current', default=None)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CaptureTrace:
|
|
16
|
+
'''HTTP-backed equivalent of obs_app's internal CaptureTrace context manager. Nested
|
|
17
|
+
instances accumulate into a single tree; only the outermost (root) instance sends the
|
|
18
|
+
whole tree to the server, on exit, via client.send_trace().
|
|
19
|
+
|
|
20
|
+
with CaptureTrace(client, "outer"):
|
|
21
|
+
with CaptureTrace(client, "inner step"):
|
|
22
|
+
do_work()
|
|
23
|
+
'''
|
|
24
|
+
|
|
25
|
+
def __init__(self, client: 'Client', text: str = '') -> None:
|
|
26
|
+
self.client = client
|
|
27
|
+
self.text = text
|
|
28
|
+
self.children: list['CaptureTrace'] = []
|
|
29
|
+
self.parent: 'CaptureTrace | None' = None
|
|
30
|
+
self.start: str | None = None
|
|
31
|
+
self.end: str | None = None
|
|
32
|
+
self._token: contextvars.Token | None = None
|
|
33
|
+
|
|
34
|
+
def __enter__(self) -> 'CaptureTrace':
|
|
35
|
+
self.start = datetime.now(timezone.utc).isoformat()
|
|
36
|
+
|
|
37
|
+
parent = _current.get()
|
|
38
|
+
if parent is not None:
|
|
39
|
+
self.parent = parent
|
|
40
|
+
parent.children.append(self)
|
|
41
|
+
|
|
42
|
+
self._token = _current.set(self)
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
46
|
+
self.end = datetime.now(timezone.utc).isoformat()
|
|
47
|
+
if self._token is not None:
|
|
48
|
+
_current.reset(self._token)
|
|
49
|
+
|
|
50
|
+
if self.parent is None:
|
|
51
|
+
self.client.send_trace(self.to_dict())
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict:
|
|
54
|
+
data = {'start': self.start, 'end': self.end}
|
|
55
|
+
if self.text:
|
|
56
|
+
data['text'] = self.text
|
|
57
|
+
|
|
58
|
+
if self.children:
|
|
59
|
+
data['traces'] = [child.to_dict() for child in self.children]
|
|
60
|
+
|
|
61
|
+
return data
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from obs_app_sdk.client import Client
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeResponse:
|
|
9
|
+
'''Minimal stand-in for requests.Response, enough to drive Client._post's branching.'''
|
|
10
|
+
|
|
11
|
+
def __init__(self, status_code: int, json_body: dict | None = None) -> None:
|
|
12
|
+
self.status_code = status_code
|
|
13
|
+
self._json_body = json_body
|
|
14
|
+
self.ok = 200 <= status_code < 300
|
|
15
|
+
|
|
16
|
+
def json(self) -> dict:
|
|
17
|
+
if self._json_body is None:
|
|
18
|
+
raise ValueError('response has no JSON body')
|
|
19
|
+
return self._json_body
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def mock_session() -> MagicMock:
|
|
24
|
+
return MagicMock()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def client(mock_session: MagicMock) -> Client:
|
|
29
|
+
return Client(api_key='test-api-key', base_url='https://obs-app.ctsoftware.co.uk', session=mock_session)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from obs_app_sdk.exceptions import ObsAppApiKeyError, ObsAppError, ObsAppRateLimitedError, ObsAppValidationError
|
|
7
|
+
|
|
8
|
+
from .conftest import FakeResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test__log__success(client, mock_session):
|
|
12
|
+
mock_session.post.return_value = FakeResponse(201, {'message': 'OK', 'id': 'abc-123'})
|
|
13
|
+
|
|
14
|
+
log_id = client.log('Bad seafood', level=2, request_id='req-1')
|
|
15
|
+
|
|
16
|
+
assert log_id == 'abc-123'
|
|
17
|
+
call = mock_session.post.call_args
|
|
18
|
+
assert call.args == ('https://obs-app.ctsoftware.co.uk/api/log/',)
|
|
19
|
+
assert call.kwargs['json'] == {'message': 'Bad seafood', 'level': 2, 'request_id': 'req-1'}
|
|
20
|
+
assert call.kwargs['headers'] == {'Authorization': 'Bearer test-api-key'}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test__log_batch__success(client, mock_session):
|
|
24
|
+
mock_session.post.return_value = FakeResponse(201, {'message': 'OK'})
|
|
25
|
+
|
|
26
|
+
client.log_batch([{'message': 'a'}, {'message': 'b'}], request_id='req-1')
|
|
27
|
+
|
|
28
|
+
call = mock_session.post.call_args
|
|
29
|
+
assert call.args == ('https://obs-app.ctsoftware.co.uk/api/logs/',)
|
|
30
|
+
assert call.kwargs['json'] == {'logs': [{'message': 'a'}, {'message': 'b'}], 'request_id': 'req-1'}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test__add_metric__requires_metric_or_metric_id(client):
|
|
34
|
+
with pytest.raises(ValueError):
|
|
35
|
+
client.add_metric(value=1.0)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test__add_metric__rejects_both_metric_and_metric_id(client):
|
|
39
|
+
with pytest.raises(ValueError):
|
|
40
|
+
client.add_metric(value=1.0, metric='TIME', metric_id='some-id')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test__add_metric__success_converts_datetime_timestamp(client, mock_session):
|
|
44
|
+
mock_session.post.return_value = FakeResponse(200, {'message': 'OK'})
|
|
45
|
+
ts = datetime(2025, 4, 30, 12, 1, 2, tzinfo=timezone.utc)
|
|
46
|
+
|
|
47
|
+
client.add_metric(value=1.0, metric='TIME', timestamp=ts, auto_create=True)
|
|
48
|
+
|
|
49
|
+
call = mock_session.post.call_args
|
|
50
|
+
assert call.args == ('https://obs-app.ctsoftware.co.uk/api/metric/',)
|
|
51
|
+
assert call.kwargs['json'] == {
|
|
52
|
+
'value': 1.0, 'auto_create': True, 'metric': 'TIME', 'timestamp': ts.isoformat(),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test__add_metrics__normalizes_per_entry_timestamps(client, mock_session):
|
|
57
|
+
mock_session.post.return_value = FakeResponse(200, {'message': 'OK'})
|
|
58
|
+
ts = datetime(2025, 4, 30, 12, 1, 2, tzinfo=timezone.utc)
|
|
59
|
+
|
|
60
|
+
client.add_metrics([
|
|
61
|
+
{'metric': 'TIME', 'value': 1.0, 'timestamp': ts},
|
|
62
|
+
{'metric': 'TIME2', 'value': 2.0},
|
|
63
|
+
], auto_create=True)
|
|
64
|
+
|
|
65
|
+
call = mock_session.post.call_args
|
|
66
|
+
assert call.args == ('https://obs-app.ctsoftware.co.uk/api/metrics/',)
|
|
67
|
+
assert call.kwargs['json'] == {
|
|
68
|
+
'metrics': [
|
|
69
|
+
{'metric': 'TIME', 'value': 1.0, 'timestamp': ts.isoformat()},
|
|
70
|
+
{'metric': 'TIME2', 'value': 2.0},
|
|
71
|
+
],
|
|
72
|
+
'auto_create': True,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test__send_trace__success(client, mock_session):
|
|
77
|
+
mock_session.post.return_value = FakeResponse(201, {'message': 'OK'})
|
|
78
|
+
trace = {'start': 'a', 'end': 'b'}
|
|
79
|
+
|
|
80
|
+
client.send_trace(trace)
|
|
81
|
+
|
|
82
|
+
call = mock_session.post.call_args
|
|
83
|
+
assert call.args == ('https://obs-app.ctsoftware.co.uk/api/trace/',)
|
|
84
|
+
assert call.kwargs['json'] == trace
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test__ingest__requires_at_least_one_section(client):
|
|
88
|
+
with pytest.raises(ValueError):
|
|
89
|
+
client.ingest()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test__ingest__success_with_all_sections(client, mock_session):
|
|
93
|
+
mock_session.post.return_value = FakeResponse(201, {'message': 'OK'})
|
|
94
|
+
|
|
95
|
+
client.ingest(
|
|
96
|
+
traces=[{'start': 'a', 'end': 'b'}],
|
|
97
|
+
logs={'logs': [{'message': 'hi'}]},
|
|
98
|
+
metrics={'metrics': [{'metric': 'TIME', 'value': 1.0}], 'auto_create': True},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
call = mock_session.post.call_args
|
|
102
|
+
assert call.args == ('https://obs-app.ctsoftware.co.uk/api/ingest/',)
|
|
103
|
+
assert call.kwargs['json'] == {
|
|
104
|
+
'traces': [{'start': 'a', 'end': 'b'}],
|
|
105
|
+
'logs': {'logs': [{'message': 'hi'}]},
|
|
106
|
+
'metrics': {'metrics': [{'metric': 'TIME', 'value': 1.0}], 'auto_create': True},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test__error__401_raises_api_key_error(client, mock_session):
|
|
111
|
+
mock_session.post.return_value = FakeResponse(401, {'error': 'Invalid API key'})
|
|
112
|
+
|
|
113
|
+
with pytest.raises(ObsAppApiKeyError) as exc_info:
|
|
114
|
+
client.log('hi')
|
|
115
|
+
assert exc_info.value.message == 'Invalid API key'
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test__error__400_with_errors_dict_raises_validation_error(client, mock_session):
|
|
119
|
+
mock_session.post.return_value = FakeResponse(400, {'error': 'Invalid data', 'errors': {'message': 'Value cannot be null'}})
|
|
120
|
+
|
|
121
|
+
with pytest.raises(ObsAppValidationError) as exc_info:
|
|
122
|
+
client.log('hi')
|
|
123
|
+
assert exc_info.value.message == 'Invalid data'
|
|
124
|
+
assert exc_info.value.errors == {'message': 'Value cannot be null'}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test__error__400_without_errors_dict(client, mock_session):
|
|
128
|
+
mock_session.post.return_value = FakeResponse(400, {'error': 'Invalid metrics: TIME'})
|
|
129
|
+
|
|
130
|
+
with pytest.raises(ObsAppValidationError) as exc_info:
|
|
131
|
+
client.add_metrics([{'metric': 'TIME', 'value': 1.0}])
|
|
132
|
+
assert exc_info.value.message == 'Invalid metrics: TIME'
|
|
133
|
+
assert exc_info.value.errors is None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test__error__429_with_json_body(client, mock_session):
|
|
137
|
+
mock_session.post.return_value = FakeResponse(429, {'error': 'Blocked'})
|
|
138
|
+
|
|
139
|
+
with pytest.raises(ObsAppRateLimitedError) as exc_info:
|
|
140
|
+
client.log('hi')
|
|
141
|
+
assert exc_info.value.message == 'Blocked'
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test__error__429_with_non_json_body(client, mock_session):
|
|
145
|
+
# the block response isn't guaranteed to be JSON - some paths render an HTML template
|
|
146
|
+
mock_session.post.return_value = FakeResponse(429, json_body=None)
|
|
147
|
+
|
|
148
|
+
with pytest.raises(ObsAppRateLimitedError):
|
|
149
|
+
client.log('hi')
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test__error__network_failure_raises_obs_app_error(client, mock_session):
|
|
153
|
+
mock_session.post.side_effect = requests.ConnectionError('boom')
|
|
154
|
+
|
|
155
|
+
with pytest.raises(ObsAppError):
|
|
156
|
+
client.log('hi')
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test__error__unexpected_status_raises_obs_app_error(client, mock_session):
|
|
160
|
+
mock_session.post.return_value = FakeResponse(500, {'error': 'Server error'})
|
|
161
|
+
|
|
162
|
+
with pytest.raises(ObsAppError):
|
|
163
|
+
client.log('hi')
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
|
|
4
|
+
from obs_app_sdk.logging_handler import ObsAppLogHandler
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _record(level: int, message: str) -> logging.LogRecord:
|
|
8
|
+
return logging.LogRecord(
|
|
9
|
+
name='test', level=level, pathname=__file__, lineno=1,
|
|
10
|
+
msg=message, args=(), exc_info=None,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test__emit_maps_levels_and_calls_client_log():
|
|
15
|
+
client = MagicMock()
|
|
16
|
+
handler = ObsAppLogHandler(client)
|
|
17
|
+
|
|
18
|
+
handler.emit(_record(logging.ERROR, 'boom'))
|
|
19
|
+
|
|
20
|
+
client.log.assert_called_once_with(message='boom', level=2)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test__level_mapping_table():
|
|
24
|
+
client = MagicMock()
|
|
25
|
+
handler = ObsAppLogHandler(client)
|
|
26
|
+
|
|
27
|
+
expected = {
|
|
28
|
+
logging.CRITICAL: 1,
|
|
29
|
+
logging.ERROR: 2,
|
|
30
|
+
logging.WARNING: 3,
|
|
31
|
+
logging.INFO: 4,
|
|
32
|
+
logging.DEBUG: 5,
|
|
33
|
+
}
|
|
34
|
+
for stdlib_level, obs_level in expected.items():
|
|
35
|
+
client.reset_mock()
|
|
36
|
+
handler.emit(_record(stdlib_level, 'msg'))
|
|
37
|
+
assert client.log.call_args.kwargs['level'] == obs_level
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test__custom_level_between_standard_levels_maps_to_lower_severity():
|
|
41
|
+
# 25 sits between INFO (20) and WARNING (30) - hasn't reached WARNING, so maps to INFO's level
|
|
42
|
+
client = MagicMock()
|
|
43
|
+
handler = ObsAppLogHandler(client)
|
|
44
|
+
|
|
45
|
+
handler.emit(_record(25, 'msg'))
|
|
46
|
+
|
|
47
|
+
assert client.log.call_args.kwargs['level'] == 4
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test__emit_swallows_client_errors_via_handle_error():
|
|
51
|
+
client = MagicMock()
|
|
52
|
+
client.log.side_effect = Exception('network down')
|
|
53
|
+
handler = ObsAppLogHandler(client)
|
|
54
|
+
handler.handleError = MagicMock()
|
|
55
|
+
|
|
56
|
+
handler.emit(_record(logging.INFO, 'msg'))
|
|
57
|
+
|
|
58
|
+
handler.handleError.assert_called_once()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
|
|
3
|
+
from obs_app_sdk.tracing import CaptureTrace
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test__single_trace_sends_on_exit():
|
|
7
|
+
client = MagicMock()
|
|
8
|
+
with CaptureTrace(client, 'root'):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
client.send_trace.assert_called_once()
|
|
12
|
+
sent = client.send_trace.call_args.args[0]
|
|
13
|
+
assert sent['text'] == 'root'
|
|
14
|
+
assert 'start' in sent and 'end' in sent
|
|
15
|
+
assert 'traces' not in sent
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test__nested_traces_send_once_as_one_tree():
|
|
19
|
+
client = MagicMock()
|
|
20
|
+
with CaptureTrace(client, 'outer'):
|
|
21
|
+
with CaptureTrace(client, 'inner-a'):
|
|
22
|
+
pass
|
|
23
|
+
with CaptureTrace(client, 'inner-b'):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
client.send_trace.assert_called_once()
|
|
27
|
+
sent = client.send_trace.call_args.args[0]
|
|
28
|
+
assert sent['text'] == 'outer'
|
|
29
|
+
assert [t['text'] for t in sent['traces']] == ['inner-a', 'inner-b']
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test__deeply_nested_traces():
|
|
33
|
+
client = MagicMock()
|
|
34
|
+
with CaptureTrace(client, 'l1'):
|
|
35
|
+
with CaptureTrace(client, 'l2'):
|
|
36
|
+
with CaptureTrace(client, 'l3'):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
client.send_trace.assert_called_once()
|
|
40
|
+
sent = client.send_trace.call_args.args[0]
|
|
41
|
+
assert sent['text'] == 'l1'
|
|
42
|
+
assert sent['traces'][0]['text'] == 'l2'
|
|
43
|
+
assert sent['traces'][0]['traces'][0]['text'] == 'l3'
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test__text_omitted_when_empty():
|
|
47
|
+
client = MagicMock()
|
|
48
|
+
with CaptureTrace(client):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
sent = client.send_trace.call_args.args[0]
|
|
52
|
+
assert 'text' not in sent
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test__sibling_root_traces_send_independently():
|
|
56
|
+
client = MagicMock()
|
|
57
|
+
with CaptureTrace(client, 'first'):
|
|
58
|
+
pass
|
|
59
|
+
with CaptureTrace(client, 'second'):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
assert client.send_trace.call_count == 2
|