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.
@@ -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
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ htmlcov/
6
+ .coverage
7
+ build/
8
+ dist/
@@ -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