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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,13 @@
1
+
2
+ [fastapi]
3
+ fastapi>=0.112.0
4
+ starlette>=0.37.0
5
+
6
+ [flask]
7
+ flask>=3.0.0
8
+
9
+ [httpx]
10
+ httpx>=0.27.0
11
+
12
+ [requests]
13
+ requests>=2.31.0
@@ -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