obtrace-sdk-python 1.0.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.
- obtrace_sdk_python-1.0.0/PKG-INFO +132 -0
- obtrace_sdk_python-1.0.0/README.md +114 -0
- obtrace_sdk_python-1.0.0/pyproject.toml +25 -0
- obtrace_sdk_python-1.0.0/setup.cfg +4 -0
- obtrace_sdk_python-1.0.0/src/obtrace_sdk/__init__.py +12 -0
- obtrace_sdk_python-1.0.0/src/obtrace_sdk/client.py +159 -0
- obtrace_sdk_python-1.0.0/src/obtrace_sdk/context.py +31 -0
- obtrace_sdk_python-1.0.0/src/obtrace_sdk/http.py +159 -0
- obtrace_sdk_python-1.0.0/src/obtrace_sdk/otlp.py +161 -0
- obtrace_sdk_python-1.0.0/src/obtrace_sdk/semantic_metrics.py +50 -0
- obtrace_sdk_python-1.0.0/src/obtrace_sdk/types.py +33 -0
- obtrace_sdk_python-1.0.0/src/obtrace_sdk_python.egg-info/PKG-INFO +132 -0
- obtrace_sdk_python-1.0.0/src/obtrace_sdk_python.egg-info/SOURCES.txt +17 -0
- obtrace_sdk_python-1.0.0/src/obtrace_sdk_python.egg-info/dependency_links.txt +1 -0
- obtrace_sdk_python-1.0.0/src/obtrace_sdk_python.egg-info/requires.txt +13 -0
- obtrace_sdk_python-1.0.0/src/obtrace_sdk_python.egg-info/top_level.txt +1 -0
- obtrace_sdk_python-1.0.0/tests/test_client.py +32 -0
- obtrace_sdk_python-1.0.0/tests/test_context.py +21 -0
- obtrace_sdk_python-1.0.0/tests/test_semantic_metrics.py +9 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: obtrace-sdk-python
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Obtrace Python SDK
|
|
5
|
+
Author: Obtrace
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Provides-Extra: requests
|
|
10
|
+
Requires-Dist: requests>=2.31.0; extra == "requests"
|
|
11
|
+
Provides-Extra: httpx
|
|
12
|
+
Requires-Dist: httpx>=0.27.0; extra == "httpx"
|
|
13
|
+
Provides-Extra: fastapi
|
|
14
|
+
Requires-Dist: fastapi>=0.112.0; extra == "fastapi"
|
|
15
|
+
Requires-Dist: starlette>=0.37.0; extra == "fastapi"
|
|
16
|
+
Provides-Extra: flask
|
|
17
|
+
Requires-Dist: flask>=3.0.0; extra == "flask"
|
|
18
|
+
|
|
19
|
+
# obtrace-sdk-python
|
|
20
|
+
|
|
21
|
+
Python backend SDK for Obtrace telemetry transport and instrumentation.
|
|
22
|
+
|
|
23
|
+
## Scope
|
|
24
|
+
- OTLP logs/traces/metrics transport
|
|
25
|
+
- Context propagation
|
|
26
|
+
- HTTP instrumentation (requests/httpx)
|
|
27
|
+
- Framework helpers (FastAPI, Flask)
|
|
28
|
+
|
|
29
|
+
## Design Principle
|
|
30
|
+
SDK is thin/dumb.
|
|
31
|
+
- No business logic authority in client SDK.
|
|
32
|
+
- Policy and product logic are server-side.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
Required:
|
|
43
|
+
- `api_key`
|
|
44
|
+
- `ingest_base_url`
|
|
45
|
+
- `service_name`
|
|
46
|
+
|
|
47
|
+
Optional (auto-resolved from API key on the server side):
|
|
48
|
+
- `tenant_id`
|
|
49
|
+
- `project_id`
|
|
50
|
+
- `app_id`
|
|
51
|
+
- `env`
|
|
52
|
+
- `service_version`
|
|
53
|
+
|
|
54
|
+
## Quickstart
|
|
55
|
+
|
|
56
|
+
### Simplified setup
|
|
57
|
+
|
|
58
|
+
The API key resolves `tenant_id`, `project_id`, `app_id`, and `env` automatically on the server side, so only three fields are needed:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from obtrace_sdk import ObtraceClient, ObtraceConfig
|
|
62
|
+
|
|
63
|
+
client = ObtraceClient(
|
|
64
|
+
ObtraceConfig(
|
|
65
|
+
api_key="obt_live_...",
|
|
66
|
+
ingest_base_url="https://ingest.obtrace.io",
|
|
67
|
+
service_name="my-service",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Full configuration
|
|
73
|
+
|
|
74
|
+
For advanced use cases you can override the resolved values explicitly:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from obtrace_sdk import ObtraceClient, ObtraceConfig, SemanticMetrics
|
|
78
|
+
|
|
79
|
+
client = ObtraceClient(
|
|
80
|
+
ObtraceConfig(
|
|
81
|
+
api_key="<API_KEY>",
|
|
82
|
+
ingest_base_url="https://inject.obtrace.ai",
|
|
83
|
+
service_name="python-api",
|
|
84
|
+
env="prod",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
client.log("info", "started")
|
|
89
|
+
client.metric(SemanticMetrics.RUNTIME_CPU_UTILIZATION, 0.41)
|
|
90
|
+
client.span(
|
|
91
|
+
"checkout.charge",
|
|
92
|
+
attrs={
|
|
93
|
+
"feature.name": "checkout",
|
|
94
|
+
"payment.provider": "stripe",
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
client.flush()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Canonical metrics and custom spans
|
|
101
|
+
|
|
102
|
+
- Use `SemanticMetrics` for the product-wide metric catalog.
|
|
103
|
+
- Custom spans use `client.span(name, attrs=...)`.
|
|
104
|
+
- Keep free-form metric names only for truly product-specific signals that are not part of the shared catalog.
|
|
105
|
+
|
|
106
|
+
## Frameworks and HTTP
|
|
107
|
+
|
|
108
|
+
- Framework helpers: FastAPI and Flask
|
|
109
|
+
- HTTP instrumentation: `requests` and `httpx`
|
|
110
|
+
- Reference docs:
|
|
111
|
+
- `docs/frameworks.md`
|
|
112
|
+
- `docs/http-instrumentation.md`
|
|
113
|
+
|
|
114
|
+
## Production Hardening
|
|
115
|
+
|
|
116
|
+
1. Keep `api_key` only in server-side secret storage.
|
|
117
|
+
2. Use one key per environment and rotate periodically.
|
|
118
|
+
3. Keep fail-open behavior (telemetry must not break request flow).
|
|
119
|
+
4. Validate ingestion after deploy using Query Gateway and ClickHouse checks.
|
|
120
|
+
|
|
121
|
+
## Troubleshooting
|
|
122
|
+
|
|
123
|
+
- No telemetry: validate `ingest_base_url`, API key, and egress connectivity.
|
|
124
|
+
- Missing correlation: ensure propagation headers are injected on outbound HTTP.
|
|
125
|
+
- Short-lived workers: call `flush()` before process exit.
|
|
126
|
+
|
|
127
|
+
## Documentation
|
|
128
|
+
- Docs index: `docs/index.md`
|
|
129
|
+
- LLM context file: `llm.txt`
|
|
130
|
+
- MCP metadata: `mcp.json`
|
|
131
|
+
|
|
132
|
+
## Reference
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# obtrace-sdk-python
|
|
2
|
+
|
|
3
|
+
Python backend SDK for Obtrace telemetry transport and instrumentation.
|
|
4
|
+
|
|
5
|
+
## Scope
|
|
6
|
+
- OTLP logs/traces/metrics transport
|
|
7
|
+
- Context propagation
|
|
8
|
+
- HTTP instrumentation (requests/httpx)
|
|
9
|
+
- Framework helpers (FastAPI, Flask)
|
|
10
|
+
|
|
11
|
+
## Design Principle
|
|
12
|
+
SDK is thin/dumb.
|
|
13
|
+
- No business logic authority in client SDK.
|
|
14
|
+
- Policy and product logic are server-side.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install .
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
Required:
|
|
25
|
+
- `api_key`
|
|
26
|
+
- `ingest_base_url`
|
|
27
|
+
- `service_name`
|
|
28
|
+
|
|
29
|
+
Optional (auto-resolved from API key on the server side):
|
|
30
|
+
- `tenant_id`
|
|
31
|
+
- `project_id`
|
|
32
|
+
- `app_id`
|
|
33
|
+
- `env`
|
|
34
|
+
- `service_version`
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
### Simplified setup
|
|
39
|
+
|
|
40
|
+
The API key resolves `tenant_id`, `project_id`, `app_id`, and `env` automatically on the server side, so only three fields are needed:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from obtrace_sdk import ObtraceClient, ObtraceConfig
|
|
44
|
+
|
|
45
|
+
client = ObtraceClient(
|
|
46
|
+
ObtraceConfig(
|
|
47
|
+
api_key="obt_live_...",
|
|
48
|
+
ingest_base_url="https://ingest.obtrace.io",
|
|
49
|
+
service_name="my-service",
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Full configuration
|
|
55
|
+
|
|
56
|
+
For advanced use cases you can override the resolved values explicitly:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from obtrace_sdk import ObtraceClient, ObtraceConfig, SemanticMetrics
|
|
60
|
+
|
|
61
|
+
client = ObtraceClient(
|
|
62
|
+
ObtraceConfig(
|
|
63
|
+
api_key="<API_KEY>",
|
|
64
|
+
ingest_base_url="https://inject.obtrace.ai",
|
|
65
|
+
service_name="python-api",
|
|
66
|
+
env="prod",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
client.log("info", "started")
|
|
71
|
+
client.metric(SemanticMetrics.RUNTIME_CPU_UTILIZATION, 0.41)
|
|
72
|
+
client.span(
|
|
73
|
+
"checkout.charge",
|
|
74
|
+
attrs={
|
|
75
|
+
"feature.name": "checkout",
|
|
76
|
+
"payment.provider": "stripe",
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
client.flush()
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Canonical metrics and custom spans
|
|
83
|
+
|
|
84
|
+
- Use `SemanticMetrics` for the product-wide metric catalog.
|
|
85
|
+
- Custom spans use `client.span(name, attrs=...)`.
|
|
86
|
+
- Keep free-form metric names only for truly product-specific signals that are not part of the shared catalog.
|
|
87
|
+
|
|
88
|
+
## Frameworks and HTTP
|
|
89
|
+
|
|
90
|
+
- Framework helpers: FastAPI and Flask
|
|
91
|
+
- HTTP instrumentation: `requests` and `httpx`
|
|
92
|
+
- Reference docs:
|
|
93
|
+
- `docs/frameworks.md`
|
|
94
|
+
- `docs/http-instrumentation.md`
|
|
95
|
+
|
|
96
|
+
## Production Hardening
|
|
97
|
+
|
|
98
|
+
1. Keep `api_key` only in server-side secret storage.
|
|
99
|
+
2. Use one key per environment and rotate periodically.
|
|
100
|
+
3. Keep fail-open behavior (telemetry must not break request flow).
|
|
101
|
+
4. Validate ingestion after deploy using Query Gateway and ClickHouse checks.
|
|
102
|
+
|
|
103
|
+
## Troubleshooting
|
|
104
|
+
|
|
105
|
+
- No telemetry: validate `ingest_base_url`, API key, and egress connectivity.
|
|
106
|
+
- Missing correlation: ensure propagation headers are injected on outbound HTTP.
|
|
107
|
+
- Short-lived workers: call `flush()` before process exit.
|
|
108
|
+
|
|
109
|
+
## Documentation
|
|
110
|
+
- Docs index: `docs/index.md`
|
|
111
|
+
- LLM context file: `llm.txt`
|
|
112
|
+
- MCP metadata: `mcp.json`
|
|
113
|
+
|
|
114
|
+
## Reference
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "obtrace-sdk-python"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Obtrace Python SDK"
|
|
9
|
+
classifiers = ["License :: OSI Approved :: MIT License"]
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
authors = [{ name = "Obtrace" }]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
requests = ["requests>=2.31.0"]
|
|
17
|
+
httpx = ["httpx>=0.27.0"]
|
|
18
|
+
fastapi = ["fastapi>=0.112.0", "starlette>=0.37.0"]
|
|
19
|
+
flask = ["flask>=3.0.0"]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
package-dir = {"" = "src"}
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .client import ObtraceClient, ObtraceConfig
|
|
2
|
+
from .context import create_traceparent, ensure_propagation_headers
|
|
3
|
+
from .semantic_metrics import SemanticMetrics, is_semantic_metric
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ObtraceClient",
|
|
7
|
+
"ObtraceConfig",
|
|
8
|
+
"SemanticMetrics",
|
|
9
|
+
"is_semantic_metric",
|
|
10
|
+
"create_traceparent",
|
|
11
|
+
"ensure_propagation_headers",
|
|
12
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from .context import ensure_propagation_headers, random_hex
|
|
13
|
+
from .otlp import build_logs_payload, build_metric_payload, build_span_payload
|
|
14
|
+
from .semantic_metrics import is_semantic_metric
|
|
15
|
+
from .types import ObtraceConfig, SDKContext
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class _Queued:
|
|
20
|
+
endpoint: str
|
|
21
|
+
payload: Dict[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ObtraceClient:
|
|
25
|
+
def __init__(self, cfg: ObtraceConfig):
|
|
26
|
+
if not cfg.api_key or not cfg.ingest_base_url or not cfg.service_name:
|
|
27
|
+
raise ValueError("api_key, ingest_base_url and service_name are required")
|
|
28
|
+
self.cfg = cfg
|
|
29
|
+
self._queue: List[_Queued] = []
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
self._circuit_failures = 0
|
|
32
|
+
self._circuit_open_until = 0.0
|
|
33
|
+
atexit.register(self.flush)
|
|
34
|
+
|
|
35
|
+
def __enter__(self) -> ObtraceClient:
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def __exit__(self, *_: Any) -> None:
|
|
39
|
+
self.flush()
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _truncate(s: str, max_len: int) -> str:
|
|
43
|
+
if len(s) <= max_len:
|
|
44
|
+
return s
|
|
45
|
+
return s[:max_len] + "...[truncated]"
|
|
46
|
+
|
|
47
|
+
def log(self, level: str, message: str, context: Optional[SDKContext] = None) -> None:
|
|
48
|
+
self._enqueue("/otlp/v1/logs", build_logs_payload(self.cfg, level, self._truncate(message, 32768), context))
|
|
49
|
+
|
|
50
|
+
def metric(self, name: str, value: float, unit: str = "1", context: Optional[SDKContext] = None) -> None:
|
|
51
|
+
if self.cfg.validate_semantic_metrics and self.cfg.debug and not is_semantic_metric(name):
|
|
52
|
+
print(f"[obtrace-sdk-python] non-canonical metric name: {name}")
|
|
53
|
+
self._enqueue("/otlp/v1/metrics", build_metric_payload(self.cfg, self._truncate(name, 1024), value, unit, context))
|
|
54
|
+
|
|
55
|
+
def span(
|
|
56
|
+
self,
|
|
57
|
+
name: str,
|
|
58
|
+
trace_id: Optional[str] = None,
|
|
59
|
+
span_id: Optional[str] = None,
|
|
60
|
+
start_unix_nano: Optional[str] = None,
|
|
61
|
+
end_unix_nano: Optional[str] = None,
|
|
62
|
+
status_code: Optional[int] = None,
|
|
63
|
+
status_message: str = "",
|
|
64
|
+
attrs: Optional[Dict[str, Any]] = None,
|
|
65
|
+
) -> Dict[str, str]:
|
|
66
|
+
t = trace_id if trace_id and len(trace_id) == 32 else random_hex(16)
|
|
67
|
+
s = span_id if span_id and len(span_id) == 16 else random_hex(8)
|
|
68
|
+
start = start_unix_nano or str(int(time.time() * 1_000_000_000))
|
|
69
|
+
end = end_unix_nano or str(int(time.time() * 1_000_000_000))
|
|
70
|
+
|
|
71
|
+
truncated_name = self._truncate(name, 32768)
|
|
72
|
+
if attrs:
|
|
73
|
+
attrs = {k: self._truncate(v, 4096) if isinstance(v, str) else v for k, v in attrs.items()}
|
|
74
|
+
|
|
75
|
+
self._enqueue(
|
|
76
|
+
"/otlp/v1/traces",
|
|
77
|
+
build_span_payload(self.cfg, truncated_name, t, s, start, end, status_code, status_message, attrs),
|
|
78
|
+
)
|
|
79
|
+
return {"trace_id": t, "span_id": s}
|
|
80
|
+
|
|
81
|
+
def inject_propagation(
|
|
82
|
+
self,
|
|
83
|
+
headers: Optional[Dict[str, str]] = None,
|
|
84
|
+
trace_id: Optional[str] = None,
|
|
85
|
+
span_id: Optional[str] = None,
|
|
86
|
+
session_id: Optional[str] = None,
|
|
87
|
+
) -> Dict[str, str]:
|
|
88
|
+
return ensure_propagation_headers(headers, trace_id, span_id, session_id)
|
|
89
|
+
|
|
90
|
+
def flush(self) -> None:
|
|
91
|
+
with self._lock:
|
|
92
|
+
now = time.time()
|
|
93
|
+
if now < self._circuit_open_until:
|
|
94
|
+
return
|
|
95
|
+
half_open = self._circuit_failures >= 5
|
|
96
|
+
if half_open:
|
|
97
|
+
batch = self._queue[:1]
|
|
98
|
+
self._queue = self._queue[1:]
|
|
99
|
+
else:
|
|
100
|
+
batch = list(self._queue)
|
|
101
|
+
self._queue.clear()
|
|
102
|
+
|
|
103
|
+
for item in batch:
|
|
104
|
+
try:
|
|
105
|
+
self._send(item)
|
|
106
|
+
with self._lock:
|
|
107
|
+
if self._circuit_failures > 0:
|
|
108
|
+
if self.cfg.debug:
|
|
109
|
+
print("[obtrace-sdk-python] circuit breaker closed")
|
|
110
|
+
self._circuit_failures = 0
|
|
111
|
+
self._circuit_open_until = 0.0
|
|
112
|
+
except Exception: # noqa: BLE001
|
|
113
|
+
with self._lock:
|
|
114
|
+
self._circuit_failures += 1
|
|
115
|
+
if self._circuit_failures >= 5:
|
|
116
|
+
self._circuit_open_until = time.time() + 30.0
|
|
117
|
+
if self.cfg.debug:
|
|
118
|
+
print("[obtrace-sdk-python] circuit breaker opened")
|
|
119
|
+
if self.cfg.debug:
|
|
120
|
+
import traceback
|
|
121
|
+
traceback.print_exc()
|
|
122
|
+
|
|
123
|
+
def shutdown(self) -> None:
|
|
124
|
+
self.flush()
|
|
125
|
+
|
|
126
|
+
def _enqueue(self, endpoint: str, payload: Dict[str, Any]) -> None:
|
|
127
|
+
with self._lock:
|
|
128
|
+
if len(self._queue) >= self.cfg.max_queue_size:
|
|
129
|
+
if self.cfg.debug:
|
|
130
|
+
print(f"[obtrace-sdk-python] queue full, dropping oldest item")
|
|
131
|
+
self._queue.pop(0)
|
|
132
|
+
self._queue.append(_Queued(endpoint=endpoint, payload=payload))
|
|
133
|
+
|
|
134
|
+
def _send(self, item: _Queued) -> None:
|
|
135
|
+
try:
|
|
136
|
+
body = json.dumps(item.payload).encode("utf-8")
|
|
137
|
+
except (TypeError, ValueError):
|
|
138
|
+
if self.cfg.debug:
|
|
139
|
+
print(f"[obtrace-sdk-python] failed to serialize payload for {item.endpoint}")
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
req = urllib.request.Request(
|
|
143
|
+
url=f"{self.cfg.ingest_base_url.rstrip('/')}{item.endpoint}",
|
|
144
|
+
method="POST",
|
|
145
|
+
data=body,
|
|
146
|
+
headers={
|
|
147
|
+
**self.cfg.default_headers,
|
|
148
|
+
"Authorization": f"Bearer {self.cfg.api_key}",
|
|
149
|
+
"Content-Type": "application/json",
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
with urllib.request.urlopen(req, timeout=self.cfg.request_timeout_sec) as res:
|
|
154
|
+
code = int(getattr(res, "status", 200))
|
|
155
|
+
if code >= 300 and self.cfg.debug:
|
|
156
|
+
print(f"[obtrace-sdk-python] status={code} endpoint={item.endpoint}")
|
|
157
|
+
except (urllib.error.URLError, TypeError, ValueError, OSError) as exc:
|
|
158
|
+
if self.cfg.debug:
|
|
159
|
+
print(f"[obtrace-sdk-python] send failed endpoint={item.endpoint} err={exc}")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def random_hex(nbytes: int) -> str:
|
|
8
|
+
return secrets.token_hex(nbytes)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_traceparent(trace_id: Optional[str] = None, span_id: Optional[str] = None) -> str:
|
|
12
|
+
t = trace_id if trace_id and len(trace_id) == 32 else random_hex(16)
|
|
13
|
+
s = span_id if span_id and len(span_id) == 16 else random_hex(8)
|
|
14
|
+
return f"00-{t}-{s}-01"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ensure_propagation_headers(
|
|
18
|
+
headers: Optional[Dict[str, str]] = None,
|
|
19
|
+
trace_id: Optional[str] = None,
|
|
20
|
+
span_id: Optional[str] = None,
|
|
21
|
+
session_id: Optional[str] = None,
|
|
22
|
+
trace_header_name: str = "traceparent",
|
|
23
|
+
session_header_name: str = "x-obtrace-session-id",
|
|
24
|
+
) -> Dict[str, str]:
|
|
25
|
+
out = dict(headers or {})
|
|
26
|
+
lower = {k.lower(): k for k in out.keys()}
|
|
27
|
+
if trace_header_name.lower() not in lower:
|
|
28
|
+
out[trace_header_name] = create_traceparent(trace_id, span_id)
|
|
29
|
+
if session_id and session_header_name.lower() not in lower:
|
|
30
|
+
out[session_header_name] = session_id
|
|
31
|
+
return out
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Callable, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from .client import ObtraceClient
|
|
7
|
+
from .types import SDKContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def instrument_requests(client: ObtraceClient, request_func: Callable[..., Any]) -> Callable[..., Any]:
|
|
11
|
+
def wrapped(method: str, url: str, **kwargs: Any) -> Any:
|
|
12
|
+
started = time.time()
|
|
13
|
+
trace = client.span(f"http.client {method.upper()}", attrs={"http.method": method.upper(), "http.url": url})
|
|
14
|
+
|
|
15
|
+
headers = dict(kwargs.pop("headers", {}) or {})
|
|
16
|
+
headers = client.inject_propagation(headers, trace_id=trace["trace_id"], span_id=trace["span_id"])
|
|
17
|
+
kwargs["headers"] = headers
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
res = request_func(method, url, **kwargs)
|
|
21
|
+
dur_ms = int((time.time() - started) * 1000)
|
|
22
|
+
client.log(
|
|
23
|
+
"info",
|
|
24
|
+
f"requests {method.upper()} {url} -> {getattr(res, 'status_code', 200)}",
|
|
25
|
+
SDKContext(
|
|
26
|
+
trace_id=trace["trace_id"],
|
|
27
|
+
span_id=trace["span_id"],
|
|
28
|
+
method=method.upper(),
|
|
29
|
+
endpoint=url,
|
|
30
|
+
status_code=int(getattr(res, "status_code", 200)),
|
|
31
|
+
attrs={"duration_ms": dur_ms},
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
return res
|
|
35
|
+
except Exception as exc: # noqa: BLE001
|
|
36
|
+
dur_ms = int((time.time() - started) * 1000)
|
|
37
|
+
client.log(
|
|
38
|
+
"error",
|
|
39
|
+
f"requests {method.upper()} {url} failed: {exc}",
|
|
40
|
+
SDKContext(
|
|
41
|
+
trace_id=trace["trace_id"],
|
|
42
|
+
span_id=trace["span_id"],
|
|
43
|
+
method=method.upper(),
|
|
44
|
+
endpoint=url,
|
|
45
|
+
attrs={"duration_ms": dur_ms},
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
return wrapped
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def instrument_httpx(client: ObtraceClient, request_func: Callable[..., Any]) -> Callable[..., Any]:
|
|
54
|
+
async def wrapped(method: str, url: str, **kwargs: Any) -> Any:
|
|
55
|
+
started = time.time()
|
|
56
|
+
trace = client.span(f"http.client {method.upper()}", attrs={"http.method": method.upper(), "http.url": url})
|
|
57
|
+
|
|
58
|
+
headers = dict(kwargs.pop("headers", {}) or {})
|
|
59
|
+
headers = client.inject_propagation(headers, trace_id=trace["trace_id"], span_id=trace["span_id"])
|
|
60
|
+
kwargs["headers"] = headers
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
res = await request_func(method, url, **kwargs)
|
|
64
|
+
dur_ms = int((time.time() - started) * 1000)
|
|
65
|
+
client.log(
|
|
66
|
+
"info",
|
|
67
|
+
f"httpx {method.upper()} {url} -> {getattr(res, 'status_code', 200)}",
|
|
68
|
+
SDKContext(
|
|
69
|
+
trace_id=trace["trace_id"],
|
|
70
|
+
span_id=trace["span_id"],
|
|
71
|
+
method=method.upper(),
|
|
72
|
+
endpoint=url,
|
|
73
|
+
status_code=int(getattr(res, "status_code", 200)),
|
|
74
|
+
attrs={"duration_ms": dur_ms},
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
return res
|
|
78
|
+
except Exception as exc: # noqa: BLE001
|
|
79
|
+
dur_ms = int((time.time() - started) * 1000)
|
|
80
|
+
client.log(
|
|
81
|
+
"error",
|
|
82
|
+
f"httpx {method.upper()} {url} failed: {exc}",
|
|
83
|
+
SDKContext(
|
|
84
|
+
trace_id=trace["trace_id"],
|
|
85
|
+
span_id=trace["span_id"],
|
|
86
|
+
method=method.upper(),
|
|
87
|
+
endpoint=url,
|
|
88
|
+
attrs={"duration_ms": dur_ms},
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
return wrapped
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def fastapi_middleware(client: ObtraceClient):
|
|
97
|
+
async def middleware(request: Any, call_next: Callable[..., Any]) -> Any:
|
|
98
|
+
started = time.time()
|
|
99
|
+
trace = client.span(
|
|
100
|
+
f"http.server {getattr(request, 'method', 'GET')}",
|
|
101
|
+
attrs={"http.method": getattr(request, "method", "GET"), "http.route": str(getattr(request, "url", ""))},
|
|
102
|
+
)
|
|
103
|
+
try:
|
|
104
|
+
response = await call_next(request)
|
|
105
|
+
dur_ms = int((time.time() - started) * 1000)
|
|
106
|
+
client.log(
|
|
107
|
+
"info",
|
|
108
|
+
f"fastapi {request.method} {request.url.path} {response.status_code}",
|
|
109
|
+
SDKContext(
|
|
110
|
+
trace_id=trace["trace_id"],
|
|
111
|
+
span_id=trace["span_id"],
|
|
112
|
+
method=request.method,
|
|
113
|
+
endpoint=request.url.path,
|
|
114
|
+
status_code=response.status_code,
|
|
115
|
+
attrs={"duration_ms": dur_ms},
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
return response
|
|
119
|
+
except Exception as exc: # noqa: BLE001
|
|
120
|
+
dur_ms = int((time.time() - started) * 1000)
|
|
121
|
+
client.log(
|
|
122
|
+
"error",
|
|
123
|
+
f"fastapi request failed: {exc}",
|
|
124
|
+
SDKContext(
|
|
125
|
+
trace_id=trace["trace_id"],
|
|
126
|
+
span_id=trace["span_id"],
|
|
127
|
+
method=getattr(request, "method", "GET"),
|
|
128
|
+
endpoint=str(getattr(request, "url", "")),
|
|
129
|
+
attrs={"duration_ms": dur_ms},
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
raise
|
|
133
|
+
|
|
134
|
+
return middleware
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def flask_before_after(client: ObtraceClient):
|
|
138
|
+
def before() -> Dict[str, Any]:
|
|
139
|
+
started = time.time()
|
|
140
|
+
trace = client.span("http.server request")
|
|
141
|
+
return {"started": started, "trace": trace}
|
|
142
|
+
|
|
143
|
+
def after(meta: Dict[str, Any], method: str, path: str, status_code: int) -> None:
|
|
144
|
+
dur_ms = int((time.time() - meta["started"]) * 1000)
|
|
145
|
+
tr = meta["trace"]
|
|
146
|
+
client.log(
|
|
147
|
+
"info",
|
|
148
|
+
f"flask {method} {path} {status_code}",
|
|
149
|
+
SDKContext(
|
|
150
|
+
trace_id=tr["trace_id"],
|
|
151
|
+
span_id=tr["span_id"],
|
|
152
|
+
method=method,
|
|
153
|
+
endpoint=path,
|
|
154
|
+
status_code=status_code,
|
|
155
|
+
attrs={"duration_ms": dur_ms},
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return before, after
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from .types import ObtraceConfig, SDKContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _now_unix_nano_str() -> str:
|
|
10
|
+
return str(int(time.time() * 1_000_000_000))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _attrs(attrs: Optional[Dict[str, Any]]) -> list[dict[str, Any]]:
|
|
14
|
+
out: list[dict[str, Any]] = []
|
|
15
|
+
if not attrs:
|
|
16
|
+
return out
|
|
17
|
+
for k, v in attrs.items():
|
|
18
|
+
if isinstance(v, bool):
|
|
19
|
+
val = {"boolValue": v}
|
|
20
|
+
elif isinstance(v, (int, float)):
|
|
21
|
+
val = {"doubleValue": float(v)}
|
|
22
|
+
else:
|
|
23
|
+
val = {"stringValue": str(v)}
|
|
24
|
+
out.append({"key": str(k), "value": val})
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resource(cfg: ObtraceConfig) -> list[dict[str, Any]]:
|
|
29
|
+
base: Dict[str, Any] = {
|
|
30
|
+
"service.name": cfg.service_name,
|
|
31
|
+
"service.version": cfg.service_version,
|
|
32
|
+
"deployment.environment": cfg.env or "dev",
|
|
33
|
+
"runtime.name": "python",
|
|
34
|
+
}
|
|
35
|
+
if cfg.tenant_id:
|
|
36
|
+
base["obtrace.tenant_id"] = cfg.tenant_id
|
|
37
|
+
if cfg.project_id:
|
|
38
|
+
base["obtrace.project_id"] = cfg.project_id
|
|
39
|
+
if cfg.app_id:
|
|
40
|
+
base["obtrace.app_id"] = cfg.app_id
|
|
41
|
+
if cfg.env:
|
|
42
|
+
base["obtrace.env"] = cfg.env
|
|
43
|
+
return _attrs(base)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_logs_payload(cfg: ObtraceConfig, level: str, body: str, ctx: Optional[SDKContext] = None) -> Dict[str, Any]:
|
|
47
|
+
context_attrs: Dict[str, Any] = {"obtrace.log.level": level}
|
|
48
|
+
if ctx:
|
|
49
|
+
if ctx.trace_id:
|
|
50
|
+
context_attrs["obtrace.trace_id"] = ctx.trace_id
|
|
51
|
+
if ctx.span_id:
|
|
52
|
+
context_attrs["obtrace.span_id"] = ctx.span_id
|
|
53
|
+
if ctx.session_id:
|
|
54
|
+
context_attrs["obtrace.session_id"] = ctx.session_id
|
|
55
|
+
if ctx.route_template:
|
|
56
|
+
context_attrs["obtrace.route_template"] = ctx.route_template
|
|
57
|
+
if ctx.endpoint:
|
|
58
|
+
context_attrs["obtrace.endpoint"] = ctx.endpoint
|
|
59
|
+
if ctx.method:
|
|
60
|
+
context_attrs["obtrace.method"] = ctx.method
|
|
61
|
+
if ctx.status_code is not None:
|
|
62
|
+
context_attrs["obtrace.status_code"] = ctx.status_code
|
|
63
|
+
for k, v in ctx.attrs.items():
|
|
64
|
+
context_attrs[f"obtrace.attr.{k}"] = v
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"resourceLogs": [
|
|
68
|
+
{
|
|
69
|
+
"resource": {"attributes": _resource(cfg)},
|
|
70
|
+
"scopeLogs": [
|
|
71
|
+
{
|
|
72
|
+
"scope": {"name": "obtrace-sdk-python", "version": "1.0.0"},
|
|
73
|
+
"logRecords": [
|
|
74
|
+
{
|
|
75
|
+
"timeUnixNano": _now_unix_nano_str(),
|
|
76
|
+
"severityText": level.upper(),
|
|
77
|
+
"body": {"stringValue": body},
|
|
78
|
+
"attributes": _attrs(context_attrs),
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_metric_payload(
|
|
89
|
+
cfg: ObtraceConfig,
|
|
90
|
+
metric_name: str,
|
|
91
|
+
value: float,
|
|
92
|
+
unit: str = "1",
|
|
93
|
+
ctx: Optional[SDKContext] = None,
|
|
94
|
+
) -> Dict[str, Any]:
|
|
95
|
+
return {
|
|
96
|
+
"resourceMetrics": [
|
|
97
|
+
{
|
|
98
|
+
"resource": {"attributes": _resource(cfg)},
|
|
99
|
+
"scopeMetrics": [
|
|
100
|
+
{
|
|
101
|
+
"scope": {"name": "obtrace-sdk-python", "version": "1.0.0"},
|
|
102
|
+
"metrics": [
|
|
103
|
+
{
|
|
104
|
+
"name": metric_name,
|
|
105
|
+
"unit": unit,
|
|
106
|
+
"gauge": {
|
|
107
|
+
"dataPoints": [
|
|
108
|
+
{
|
|
109
|
+
"timeUnixNano": _now_unix_nano_str(),
|
|
110
|
+
"asDouble": float(value),
|
|
111
|
+
"attributes": _attrs(ctx.attrs if ctx else None),
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
],
|
|
117
|
+
}
|
|
118
|
+
],
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_span_payload(
|
|
125
|
+
cfg: ObtraceConfig,
|
|
126
|
+
name: str,
|
|
127
|
+
trace_id: str,
|
|
128
|
+
span_id: str,
|
|
129
|
+
start_unix_nano: str,
|
|
130
|
+
end_unix_nano: str,
|
|
131
|
+
status_code: Optional[int] = None,
|
|
132
|
+
status_message: str = "",
|
|
133
|
+
attrs: Optional[Dict[str, Any]] = None,
|
|
134
|
+
) -> Dict[str, Any]:
|
|
135
|
+
return {
|
|
136
|
+
"resourceSpans": [
|
|
137
|
+
{
|
|
138
|
+
"resource": {"attributes": _resource(cfg)},
|
|
139
|
+
"scopeSpans": [
|
|
140
|
+
{
|
|
141
|
+
"scope": {"name": "obtrace-sdk-python", "version": "1.0.0"},
|
|
142
|
+
"spans": [
|
|
143
|
+
{
|
|
144
|
+
"traceId": trace_id,
|
|
145
|
+
"spanId": span_id,
|
|
146
|
+
"name": name,
|
|
147
|
+
"kind": 3,
|
|
148
|
+
"startTimeUnixNano": start_unix_nano,
|
|
149
|
+
"endTimeUnixNano": end_unix_nano,
|
|
150
|
+
"attributes": _attrs(attrs),
|
|
151
|
+
"status": {
|
|
152
|
+
"code": 2 if (status_code is not None and status_code >= 400) else 1,
|
|
153
|
+
"message": status_message,
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
],
|
|
157
|
+
}
|
|
158
|
+
],
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
class SemanticMetrics:
|
|
2
|
+
THROUGHPUT = "http_requests_total"
|
|
3
|
+
ERROR_RATE = "http_5xx_total"
|
|
4
|
+
LATENCY_P95 = "latency_p95"
|
|
5
|
+
RUNTIME_CPU_UTILIZATION = "runtime.cpu.utilization"
|
|
6
|
+
RUNTIME_MEMORY_USAGE = "runtime.memory.usage"
|
|
7
|
+
RUNTIME_THREAD_COUNT = "runtime.thread.count"
|
|
8
|
+
RUNTIME_GC_PAUSE = "runtime.gc.pause"
|
|
9
|
+
RUNTIME_EVENTLOOP_LAG = "runtime.eventloop.lag"
|
|
10
|
+
CLUSTER_CPU_UTILIZATION = "cluster.cpu.utilization"
|
|
11
|
+
CLUSTER_MEMORY_USAGE = "cluster.memory.usage"
|
|
12
|
+
CLUSTER_NODE_COUNT = "cluster.node.count"
|
|
13
|
+
CLUSTER_POD_COUNT = "cluster.pod.count"
|
|
14
|
+
DB_OPERATION_LATENCY = "db.operation.latency"
|
|
15
|
+
DB_CLIENT_ERRORS = "db.client.errors"
|
|
16
|
+
DB_CONNECTIONS_USAGE = "db.connections.usage"
|
|
17
|
+
MESSAGING_CONSUMER_LAG = "messaging.consumer.lag"
|
|
18
|
+
WEB_VITAL_LCP = "web.vital.lcp"
|
|
19
|
+
WEB_VITAL_FCP = "web.vital.fcp"
|
|
20
|
+
WEB_VITAL_INP = "web.vital.inp"
|
|
21
|
+
WEB_VITAL_CLS = "web.vital.cls"
|
|
22
|
+
WEB_VITAL_TTFB = "web.vital.ttfb"
|
|
23
|
+
USER_ACTIONS = "obtrace.sim.web.react.actions"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_semantic_metric(name: str) -> bool:
|
|
27
|
+
return name in {
|
|
28
|
+
SemanticMetrics.THROUGHPUT,
|
|
29
|
+
SemanticMetrics.ERROR_RATE,
|
|
30
|
+
SemanticMetrics.LATENCY_P95,
|
|
31
|
+
SemanticMetrics.RUNTIME_CPU_UTILIZATION,
|
|
32
|
+
SemanticMetrics.RUNTIME_MEMORY_USAGE,
|
|
33
|
+
SemanticMetrics.RUNTIME_THREAD_COUNT,
|
|
34
|
+
SemanticMetrics.RUNTIME_GC_PAUSE,
|
|
35
|
+
SemanticMetrics.RUNTIME_EVENTLOOP_LAG,
|
|
36
|
+
SemanticMetrics.CLUSTER_CPU_UTILIZATION,
|
|
37
|
+
SemanticMetrics.CLUSTER_MEMORY_USAGE,
|
|
38
|
+
SemanticMetrics.CLUSTER_NODE_COUNT,
|
|
39
|
+
SemanticMetrics.CLUSTER_POD_COUNT,
|
|
40
|
+
SemanticMetrics.DB_OPERATION_LATENCY,
|
|
41
|
+
SemanticMetrics.DB_CLIENT_ERRORS,
|
|
42
|
+
SemanticMetrics.DB_CONNECTIONS_USAGE,
|
|
43
|
+
SemanticMetrics.MESSAGING_CONSUMER_LAG,
|
|
44
|
+
SemanticMetrics.WEB_VITAL_LCP,
|
|
45
|
+
SemanticMetrics.WEB_VITAL_FCP,
|
|
46
|
+
SemanticMetrics.WEB_VITAL_INP,
|
|
47
|
+
SemanticMetrics.WEB_VITAL_CLS,
|
|
48
|
+
SemanticMetrics.WEB_VITAL_TTFB,
|
|
49
|
+
SemanticMetrics.USER_ACTIONS,
|
|
50
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class ObtraceConfig:
|
|
9
|
+
api_key: str
|
|
10
|
+
ingest_base_url: str
|
|
11
|
+
service_name: str
|
|
12
|
+
service_version: str = "0.0.0"
|
|
13
|
+
tenant_id: Optional[str] = None
|
|
14
|
+
project_id: Optional[str] = None
|
|
15
|
+
app_id: Optional[str] = None
|
|
16
|
+
env: Optional[str] = None
|
|
17
|
+
request_timeout_sec: float = 5.0
|
|
18
|
+
max_queue_size: int = 1000
|
|
19
|
+
validate_semantic_metrics: bool = False
|
|
20
|
+
debug: bool = False
|
|
21
|
+
default_headers: Dict[str, str] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class SDKContext:
|
|
26
|
+
trace_id: Optional[str] = None
|
|
27
|
+
span_id: Optional[str] = None
|
|
28
|
+
session_id: Optional[str] = None
|
|
29
|
+
route_template: Optional[str] = None
|
|
30
|
+
endpoint: Optional[str] = None
|
|
31
|
+
method: Optional[str] = None
|
|
32
|
+
status_code: Optional[int] = None
|
|
33
|
+
attrs: Dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: obtrace-sdk-python
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Obtrace Python SDK
|
|
5
|
+
Author: Obtrace
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Provides-Extra: requests
|
|
10
|
+
Requires-Dist: requests>=2.31.0; extra == "requests"
|
|
11
|
+
Provides-Extra: httpx
|
|
12
|
+
Requires-Dist: httpx>=0.27.0; extra == "httpx"
|
|
13
|
+
Provides-Extra: fastapi
|
|
14
|
+
Requires-Dist: fastapi>=0.112.0; extra == "fastapi"
|
|
15
|
+
Requires-Dist: starlette>=0.37.0; extra == "fastapi"
|
|
16
|
+
Provides-Extra: flask
|
|
17
|
+
Requires-Dist: flask>=3.0.0; extra == "flask"
|
|
18
|
+
|
|
19
|
+
# obtrace-sdk-python
|
|
20
|
+
|
|
21
|
+
Python backend SDK for Obtrace telemetry transport and instrumentation.
|
|
22
|
+
|
|
23
|
+
## Scope
|
|
24
|
+
- OTLP logs/traces/metrics transport
|
|
25
|
+
- Context propagation
|
|
26
|
+
- HTTP instrumentation (requests/httpx)
|
|
27
|
+
- Framework helpers (FastAPI, Flask)
|
|
28
|
+
|
|
29
|
+
## Design Principle
|
|
30
|
+
SDK is thin/dumb.
|
|
31
|
+
- No business logic authority in client SDK.
|
|
32
|
+
- Policy and product logic are server-side.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
Required:
|
|
43
|
+
- `api_key`
|
|
44
|
+
- `ingest_base_url`
|
|
45
|
+
- `service_name`
|
|
46
|
+
|
|
47
|
+
Optional (auto-resolved from API key on the server side):
|
|
48
|
+
- `tenant_id`
|
|
49
|
+
- `project_id`
|
|
50
|
+
- `app_id`
|
|
51
|
+
- `env`
|
|
52
|
+
- `service_version`
|
|
53
|
+
|
|
54
|
+
## Quickstart
|
|
55
|
+
|
|
56
|
+
### Simplified setup
|
|
57
|
+
|
|
58
|
+
The API key resolves `tenant_id`, `project_id`, `app_id`, and `env` automatically on the server side, so only three fields are needed:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from obtrace_sdk import ObtraceClient, ObtraceConfig
|
|
62
|
+
|
|
63
|
+
client = ObtraceClient(
|
|
64
|
+
ObtraceConfig(
|
|
65
|
+
api_key="obt_live_...",
|
|
66
|
+
ingest_base_url="https://ingest.obtrace.io",
|
|
67
|
+
service_name="my-service",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Full configuration
|
|
73
|
+
|
|
74
|
+
For advanced use cases you can override the resolved values explicitly:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from obtrace_sdk import ObtraceClient, ObtraceConfig, SemanticMetrics
|
|
78
|
+
|
|
79
|
+
client = ObtraceClient(
|
|
80
|
+
ObtraceConfig(
|
|
81
|
+
api_key="<API_KEY>",
|
|
82
|
+
ingest_base_url="https://inject.obtrace.ai",
|
|
83
|
+
service_name="python-api",
|
|
84
|
+
env="prod",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
client.log("info", "started")
|
|
89
|
+
client.metric(SemanticMetrics.RUNTIME_CPU_UTILIZATION, 0.41)
|
|
90
|
+
client.span(
|
|
91
|
+
"checkout.charge",
|
|
92
|
+
attrs={
|
|
93
|
+
"feature.name": "checkout",
|
|
94
|
+
"payment.provider": "stripe",
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
client.flush()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Canonical metrics and custom spans
|
|
101
|
+
|
|
102
|
+
- Use `SemanticMetrics` for the product-wide metric catalog.
|
|
103
|
+
- Custom spans use `client.span(name, attrs=...)`.
|
|
104
|
+
- Keep free-form metric names only for truly product-specific signals that are not part of the shared catalog.
|
|
105
|
+
|
|
106
|
+
## Frameworks and HTTP
|
|
107
|
+
|
|
108
|
+
- Framework helpers: FastAPI and Flask
|
|
109
|
+
- HTTP instrumentation: `requests` and `httpx`
|
|
110
|
+
- Reference docs:
|
|
111
|
+
- `docs/frameworks.md`
|
|
112
|
+
- `docs/http-instrumentation.md`
|
|
113
|
+
|
|
114
|
+
## Production Hardening
|
|
115
|
+
|
|
116
|
+
1. Keep `api_key` only in server-side secret storage.
|
|
117
|
+
2. Use one key per environment and rotate periodically.
|
|
118
|
+
3. Keep fail-open behavior (telemetry must not break request flow).
|
|
119
|
+
4. Validate ingestion after deploy using Query Gateway and ClickHouse checks.
|
|
120
|
+
|
|
121
|
+
## Troubleshooting
|
|
122
|
+
|
|
123
|
+
- No telemetry: validate `ingest_base_url`, API key, and egress connectivity.
|
|
124
|
+
- Missing correlation: ensure propagation headers are injected on outbound HTTP.
|
|
125
|
+
- Short-lived workers: call `flush()` before process exit.
|
|
126
|
+
|
|
127
|
+
## Documentation
|
|
128
|
+
- Docs index: `docs/index.md`
|
|
129
|
+
- LLM context file: `llm.txt`
|
|
130
|
+
- MCP metadata: `mcp.json`
|
|
131
|
+
|
|
132
|
+
## Reference
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/obtrace_sdk/__init__.py
|
|
4
|
+
src/obtrace_sdk/client.py
|
|
5
|
+
src/obtrace_sdk/context.py
|
|
6
|
+
src/obtrace_sdk/http.py
|
|
7
|
+
src/obtrace_sdk/otlp.py
|
|
8
|
+
src/obtrace_sdk/semantic_metrics.py
|
|
9
|
+
src/obtrace_sdk/types.py
|
|
10
|
+
src/obtrace_sdk_python.egg-info/PKG-INFO
|
|
11
|
+
src/obtrace_sdk_python.egg-info/SOURCES.txt
|
|
12
|
+
src/obtrace_sdk_python.egg-info/dependency_links.txt
|
|
13
|
+
src/obtrace_sdk_python.egg-info/requires.txt
|
|
14
|
+
src/obtrace_sdk_python.egg-info/top_level.txt
|
|
15
|
+
tests/test_client.py
|
|
16
|
+
tests/test_context.py
|
|
17
|
+
tests/test_semantic_metrics.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
obtrace_sdk
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest.mock import patch, MagicMock
|
|
3
|
+
|
|
4
|
+
from obtrace_sdk.client import ObtraceClient
|
|
5
|
+
from obtrace_sdk.types import ObtraceConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ClientTests(unittest.TestCase):
|
|
9
|
+
def test_enqueue_and_flush(self):
|
|
10
|
+
c = ObtraceClient(
|
|
11
|
+
ObtraceConfig(
|
|
12
|
+
api_key="devkey",
|
|
13
|
+
ingest_base_url="https://inject.obtrace.ai",
|
|
14
|
+
service_name="py-test",
|
|
15
|
+
)
|
|
16
|
+
)
|
|
17
|
+
c.log("info", "hello")
|
|
18
|
+
c.metric("m", 1)
|
|
19
|
+
c.span("s")
|
|
20
|
+
|
|
21
|
+
with patch("urllib.request.urlopen") as urlopen:
|
|
22
|
+
cm = MagicMock()
|
|
23
|
+
cm.__enter__.return_value.status = 202
|
|
24
|
+
cm.__exit__.return_value = False
|
|
25
|
+
urlopen.return_value = cm
|
|
26
|
+
c.flush()
|
|
27
|
+
|
|
28
|
+
self.assertEqual(urlopen.call_count, 3)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
unittest.main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from obtrace_sdk.context import create_traceparent, ensure_propagation_headers
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ContextTests(unittest.TestCase):
|
|
7
|
+
def test_create_traceparent(self):
|
|
8
|
+
tp = create_traceparent()
|
|
9
|
+
self.assertTrue(tp.startswith("00-"))
|
|
10
|
+
parts = tp.split("-")
|
|
11
|
+
self.assertEqual(len(parts[1]), 32)
|
|
12
|
+
self.assertEqual(len(parts[2]), 16)
|
|
13
|
+
|
|
14
|
+
def test_ensure_propagation_headers(self):
|
|
15
|
+
out = ensure_propagation_headers({}, session_id="s1")
|
|
16
|
+
self.assertIn("traceparent", {k.lower(): v for k, v in out.items()})
|
|
17
|
+
self.assertIn("x-obtrace-session-id", {k.lower(): v for k, v in out.items()})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
unittest.main()
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from obtrace_sdk import SemanticMetrics, is_semantic_metric
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_semantic_metrics_expose_canonical_names() -> None:
|
|
5
|
+
assert SemanticMetrics.RUNTIME_CPU_UTILIZATION == "runtime.cpu.utilization"
|
|
6
|
+
assert SemanticMetrics.DB_OPERATION_LATENCY == "db.operation.latency"
|
|
7
|
+
assert SemanticMetrics.WEB_VITAL_INP == "web.vital.inp"
|
|
8
|
+
assert is_semantic_metric(SemanticMetrics.WEB_VITAL_INP) is True
|
|
9
|
+
assert is_semantic_metric("orders.count") is False
|