obtrace-sdk-python 1.0.0__tar.gz → 2.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.
Files changed (27) hide show
  1. obtrace_sdk_python-2.0.0/LICENSE +21 -0
  2. {obtrace_sdk_python-1.0.0 → obtrace_sdk_python-2.0.0}/PKG-INFO +22 -12
  3. {obtrace_sdk_python-1.0.0 → obtrace_sdk_python-2.0.0}/README.md +1 -1
  4. obtrace_sdk_python-2.0.0/pyproject.toml +38 -0
  5. obtrace_sdk_python-2.0.0/src/obtrace_sdk/__init__.py +17 -0
  6. obtrace_sdk_python-2.0.0/src/obtrace_sdk/client.py +169 -0
  7. obtrace_sdk_python-2.0.0/src/obtrace_sdk/logging_handler.py +23 -0
  8. obtrace_sdk_python-2.0.0/src/obtrace_sdk/otel_setup.py +142 -0
  9. {obtrace_sdk_python-1.0.0 → obtrace_sdk_python-2.0.0}/src/obtrace_sdk/types.py +1 -1
  10. {obtrace_sdk_python-1.0.0 → obtrace_sdk_python-2.0.0}/src/obtrace_sdk_python.egg-info/PKG-INFO +22 -12
  11. {obtrace_sdk_python-1.0.0 → obtrace_sdk_python-2.0.0}/src/obtrace_sdk_python.egg-info/SOURCES.txt +3 -4
  12. obtrace_sdk_python-2.0.0/src/obtrace_sdk_python.egg-info/requires.txt +18 -0
  13. obtrace_sdk_python-2.0.0/tests/test_client.py +91 -0
  14. obtrace_sdk_python-1.0.0/pyproject.toml +0 -25
  15. obtrace_sdk_python-1.0.0/src/obtrace_sdk/__init__.py +0 -12
  16. obtrace_sdk_python-1.0.0/src/obtrace_sdk/client.py +0 -159
  17. obtrace_sdk_python-1.0.0/src/obtrace_sdk/context.py +0 -31
  18. obtrace_sdk_python-1.0.0/src/obtrace_sdk/http.py +0 -159
  19. obtrace_sdk_python-1.0.0/src/obtrace_sdk/otlp.py +0 -161
  20. obtrace_sdk_python-1.0.0/src/obtrace_sdk_python.egg-info/requires.txt +0 -13
  21. obtrace_sdk_python-1.0.0/tests/test_client.py +0 -32
  22. obtrace_sdk_python-1.0.0/tests/test_context.py +0 -21
  23. {obtrace_sdk_python-1.0.0 → obtrace_sdk_python-2.0.0}/setup.cfg +0 -0
  24. {obtrace_sdk_python-1.0.0 → obtrace_sdk_python-2.0.0}/src/obtrace_sdk/semantic_metrics.py +0 -0
  25. {obtrace_sdk_python-1.0.0 → obtrace_sdk_python-2.0.0}/src/obtrace_sdk_python.egg-info/dependency_links.txt +0 -0
  26. {obtrace_sdk_python-1.0.0 → obtrace_sdk_python-2.0.0}/src/obtrace_sdk_python.egg-info/top_level.txt +0 -0
  27. {obtrace_sdk_python-1.0.0 → obtrace_sdk_python-2.0.0}/tests/test_semantic_metrics.py +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-present Obtrace
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.
@@ -1,20 +1,30 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obtrace-sdk-python
3
- Version: 1.0.0
3
+ Version: 2.0.0
4
4
  Summary: Obtrace Python SDK
5
5
  Author: Obtrace
6
- Classifier: License :: OSI Approved :: MIT License
6
+ License-Expression: MIT
7
7
  Requires-Python: >=3.10
8
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"
9
+ License-File: LICENSE
10
+ Requires-Dist: opentelemetry-sdk>=1.20.0
11
+ Requires-Dist: opentelemetry-api>=1.20.0
12
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20.0
13
+ Requires-Dist: opentelemetry-instrumentation-requests
14
+ Requires-Dist: opentelemetry-instrumentation-httpx
15
+ Requires-Dist: opentelemetry-instrumentation-urllib
16
+ Requires-Dist: opentelemetry-instrumentation-flask
17
+ Requires-Dist: opentelemetry-instrumentation-fastapi
18
+ Requires-Dist: opentelemetry-instrumentation-django
19
+ Requires-Dist: opentelemetry-instrumentation-logging
20
+ Requires-Dist: opentelemetry-instrumentation-psycopg2
21
+ Requires-Dist: opentelemetry-instrumentation-redis
22
+ Requires-Dist: opentelemetry-instrumentation-sqlalchemy
23
+ Requires-Dist: opentelemetry-instrumentation-celery
24
+ Requires-Dist: opentelemetry-instrumentation-grpc
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0; extra == "dev"
27
+ Dynamic: license-file
18
28
 
19
29
  # obtrace-sdk-python
20
30
 
@@ -34,7 +44,7 @@ SDK is thin/dumb.
34
44
  ## Install
35
45
 
36
46
  ```bash
37
- pip install .
47
+ pip install obtrace-sdk-python
38
48
  ```
39
49
 
40
50
  ## Configuration
@@ -16,7 +16,7 @@ SDK is thin/dumb.
16
16
  ## Install
17
17
 
18
18
  ```bash
19
- pip install .
19
+ pip install obtrace-sdk-python
20
20
  ```
21
21
 
22
22
  ## Configuration
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "obtrace-sdk-python"
7
+ version = "2.0.0"
8
+ description = "Obtrace Python SDK"
9
+ license = "MIT"
10
+ requires-python = ">=3.10"
11
+ readme = "README.md"
12
+ authors = [{ name = "Obtrace" }]
13
+ dependencies = [
14
+ "opentelemetry-sdk>=1.20.0",
15
+ "opentelemetry-api>=1.20.0",
16
+ "opentelemetry-exporter-otlp-proto-http>=1.20.0",
17
+ "opentelemetry-instrumentation-requests",
18
+ "opentelemetry-instrumentation-httpx",
19
+ "opentelemetry-instrumentation-urllib",
20
+ "opentelemetry-instrumentation-flask",
21
+ "opentelemetry-instrumentation-fastapi",
22
+ "opentelemetry-instrumentation-django",
23
+ "opentelemetry-instrumentation-logging",
24
+ "opentelemetry-instrumentation-psycopg2",
25
+ "opentelemetry-instrumentation-redis",
26
+ "opentelemetry-instrumentation-sqlalchemy",
27
+ "opentelemetry-instrumentation-celery",
28
+ "opentelemetry-instrumentation-grpc",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest>=8.0"]
33
+
34
+ [tool.setuptools]
35
+ package-dir = {"" = "src"}
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
@@ -0,0 +1,17 @@
1
+ from .client import ObtraceClient
2
+ from .logging_handler import ObtraceLoggingHandler, install_logging_hook
3
+ from .otel_setup import setup_otel, OtelProviders
4
+ from .semantic_metrics import SemanticMetrics, is_semantic_metric
5
+ from .types import ObtraceConfig, SDKContext
6
+
7
+ __all__ = [
8
+ "ObtraceClient",
9
+ "ObtraceConfig",
10
+ "ObtraceLoggingHandler",
11
+ "OtelProviders",
12
+ "SDKContext",
13
+ "SemanticMetrics",
14
+ "install_logging_hook",
15
+ "is_semantic_metric",
16
+ "setup_otel",
17
+ ]
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from contextlib import contextmanager
5
+ from typing import Any, Dict, Iterator, Optional
6
+
7
+ from opentelemetry._logs.severity import SeverityNumber
8
+ from opentelemetry.instrumentation.logging.handler import LoggingHandler
9
+ from opentelemetry.trace import StatusCode
10
+
11
+ from .otel_setup import setup_otel
12
+ from .semantic_metrics import is_semantic_metric
13
+ from .types import ObtraceConfig, SDKContext
14
+
15
+ _SDK_SCOPE = "obtrace-sdk-python"
16
+ _logger = logging.getLogger("obtrace")
17
+ _initialized = False
18
+
19
+
20
+ class ObtraceClient:
21
+ def __init__(self, cfg: ObtraceConfig):
22
+ global _initialized
23
+ if not cfg.api_key or not cfg.ingest_base_url or not cfg.service_name:
24
+ raise ValueError("api_key, ingest_base_url and service_name are required")
25
+ if _initialized:
26
+ _logger.warning("obtrace: ObtraceClient already initialized, creating duplicate instance")
27
+ _initialized = True
28
+ self.cfg = cfg
29
+ self._providers = setup_otel(cfg)
30
+ self._tracer = self._providers.tracer_provider.get_tracer(_SDK_SCOPE)
31
+ self._meter = self._providers.meter_provider.get_meter(_SDK_SCOPE)
32
+ self._logger = self._providers.logger_provider.get_logger(_SDK_SCOPE)
33
+ self._counters: Dict[str, Any] = {}
34
+ self._histograms: Dict[str, Any] = {}
35
+ self._otel_logging_handler = LoggingHandler(
36
+ level=logging.DEBUG,
37
+ logger_provider=self._providers.logger_provider,
38
+ )
39
+ logging.root.addHandler(self._otel_logging_handler)
40
+
41
+ def __enter__(self) -> ObtraceClient:
42
+ return self
43
+
44
+ def __exit__(self, *_: Any) -> None:
45
+ self.shutdown()
46
+
47
+ def log(self, level: str, message: str, context: Optional[SDKContext] = None) -> None:
48
+ severity = _level_to_severity(level)
49
+ attrs: Dict[str, Any] = {}
50
+ if context:
51
+ if context.trace_id:
52
+ attrs["obtrace.trace_id"] = context.trace_id
53
+ if context.span_id:
54
+ attrs["obtrace.span_id"] = context.span_id
55
+ if context.session_id:
56
+ attrs["obtrace.session_id"] = context.session_id
57
+ if context.route_template:
58
+ attrs["obtrace.route_template"] = context.route_template
59
+ if context.endpoint:
60
+ attrs["obtrace.endpoint"] = context.endpoint
61
+ if context.method:
62
+ attrs["obtrace.method"] = context.method
63
+ if context.status_code is not None:
64
+ attrs["obtrace.status_code"] = context.status_code
65
+ for k, v in context.attrs.items():
66
+ attrs[f"obtrace.attr.{k}"] = v
67
+ self._logger.emit(
68
+ body=message,
69
+ severity_text=level.upper(),
70
+ severity_number=_severity_number_enum(severity),
71
+ attributes=attrs if attrs else None,
72
+ )
73
+
74
+ def metric(self, name: str, value: float, unit: str = "1", context: Optional[SDKContext] = None) -> None:
75
+ if self.cfg.validate_semantic_metrics and self.cfg.debug and not is_semantic_metric(name):
76
+ print(f"[obtrace-sdk-python] non-canonical metric name: {name}")
77
+ attrs = {}
78
+ if context:
79
+ attrs = dict(context.attrs)
80
+ key = f"{name}:{unit}"
81
+ if key not in self._counters:
82
+ self._counters[key] = self._meter.create_gauge(name, unit=unit)
83
+ self._counters[key].set(value, attributes=attrs)
84
+
85
+ def span(
86
+ self,
87
+ name: str,
88
+ trace_id: Optional[str] = None,
89
+ span_id: Optional[str] = None,
90
+ start_unix_nano: Optional[str] = None,
91
+ end_unix_nano: Optional[str] = None,
92
+ status_code: Optional[int] = None,
93
+ status_message: str = "",
94
+ attrs: Optional[Dict[str, Any]] = None,
95
+ ) -> Dict[str, str]:
96
+ otel_span = self._tracer.start_span(name, attributes=attrs or {})
97
+ if status_code is not None and status_code >= 400:
98
+ otel_span.set_status(StatusCode.ERROR, status_message)
99
+ else:
100
+ otel_span.set_status(StatusCode.OK)
101
+ otel_span.end()
102
+ ctx = otel_span.get_span_context()
103
+ return {
104
+ "trace_id": format(ctx.trace_id, "032x"),
105
+ "span_id": format(ctx.span_id, "016x"),
106
+ }
107
+
108
+ @contextmanager
109
+ def start_span(self, name: str, attrs: Optional[Dict[str, Any]] = None) -> Iterator[Any]:
110
+ with self._tracer.start_as_current_span(name, attributes=attrs or {}) as otel_span:
111
+ yield otel_span
112
+
113
+ def capture_error(self, error: Exception, attrs: Optional[Dict[str, Any]] = None) -> None:
114
+ with self._tracer.start_as_current_span("exception") as otel_span:
115
+ otel_span.set_status(StatusCode.ERROR, str(error))
116
+ otel_span.record_exception(error, attributes=attrs or {})
117
+
118
+ def inject_propagation(
119
+ self,
120
+ headers: Optional[Dict[str, str]] = None,
121
+ trace_id: Optional[str] = None,
122
+ span_id: Optional[str] = None,
123
+ session_id: Optional[str] = None,
124
+ ) -> Dict[str, str]:
125
+ from opentelemetry.propagate import inject
126
+ out = dict(headers or {})
127
+ inject(out)
128
+ if session_id:
129
+ out.setdefault("x-obtrace-session-id", session_id)
130
+ return out
131
+
132
+ def flush(self) -> None:
133
+ self._providers.tracer_provider.force_flush()
134
+ self._providers.meter_provider.force_flush()
135
+ self._providers.logger_provider.force_flush()
136
+
137
+ def shutdown(self) -> None:
138
+ global _initialized
139
+ logging.root.removeHandler(self._otel_logging_handler)
140
+ self._providers.tracer_provider.shutdown()
141
+ self._providers.meter_provider.shutdown()
142
+ self._providers.logger_provider.shutdown()
143
+ _initialized = False
144
+
145
+
146
+ def _level_to_severity(level: str) -> int:
147
+ mapping = {
148
+ "debug": 5,
149
+ "info": 9,
150
+ "warn": 13,
151
+ "warning": 13,
152
+ "error": 17,
153
+ "fatal": 21,
154
+ "critical": 21,
155
+ }
156
+ return mapping.get(level.lower(), 9)
157
+
158
+
159
+ _SEVERITY_MAP = {
160
+ 5: SeverityNumber.DEBUG,
161
+ 9: SeverityNumber.INFO,
162
+ 13: SeverityNumber.WARN,
163
+ 17: SeverityNumber.ERROR,
164
+ 21: SeverityNumber.FATAL,
165
+ }
166
+
167
+
168
+ def _severity_number_enum(num: int) -> SeverityNumber:
169
+ return _SEVERITY_MAP.get(num, SeverityNumber.INFO)
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING
5
+
6
+ from opentelemetry.instrumentation.logging.handler import LoggingHandler
7
+
8
+ if TYPE_CHECKING:
9
+ from .client import ObtraceClient
10
+
11
+
12
+ class ObtraceLoggingHandler(LoggingHandler):
13
+ def __init__(self, client: ObtraceClient, level: int = logging.DEBUG):
14
+ super().__init__(
15
+ level=level,
16
+ logger_provider=client._providers.logger_provider,
17
+ )
18
+
19
+
20
+ def install_logging_hook(client: ObtraceClient, level: int = logging.DEBUG) -> ObtraceLoggingHandler:
21
+ handler = ObtraceLoggingHandler(client, level)
22
+ logging.root.addHandler(handler)
23
+ return handler
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any, Optional
5
+
6
+ logger = logging.getLogger("obtrace")
7
+
8
+ from opentelemetry import metrics, trace
9
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
10
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
11
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
12
+ from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
13
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
14
+ from opentelemetry.sdk.metrics import MeterProvider
15
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
16
+ from opentelemetry.sdk.resources import Resource
17
+ from opentelemetry.sdk.trace import TracerProvider
18
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
19
+
20
+ from .types import ObtraceConfig
21
+
22
+ _INSTRUMENTATION_MODULES = [
23
+ ("opentelemetry.instrumentation.requests", "RequestsInstrumentor"),
24
+ ("opentelemetry.instrumentation.httpx", "HTTPXClientInstrumentor"),
25
+ ("opentelemetry.instrumentation.urllib", "URLLibInstrumentor"),
26
+ ("opentelemetry.instrumentation.flask", "FlaskInstrumentor"),
27
+ ("opentelemetry.instrumentation.fastapi", "FastAPIInstrumentor"),
28
+ ("opentelemetry.instrumentation.django", "DjangoInstrumentor"),
29
+ ("opentelemetry.instrumentation.logging", "LoggingInstrumentor"),
30
+ ("opentelemetry.instrumentation.psycopg2", "Psycopg2Instrumentor"),
31
+ ("opentelemetry.instrumentation.redis", "RedisInstrumentor"),
32
+ ("opentelemetry.instrumentation.sqlalchemy", "SQLAlchemyInstrumentor"),
33
+ ("opentelemetry.instrumentation.celery", "CeleryInstrumentor"),
34
+ ("opentelemetry.instrumentation.grpc", "GrpcInstrumentorClient"),
35
+ ]
36
+
37
+
38
+ class OtelProviders:
39
+ __slots__ = ("tracer_provider", "meter_provider", "logger_provider")
40
+
41
+ def __init__(
42
+ self,
43
+ tracer_provider: TracerProvider,
44
+ meter_provider: MeterProvider,
45
+ logger_provider: LoggerProvider,
46
+ ):
47
+ self.tracer_provider = tracer_provider
48
+ self.meter_provider = meter_provider
49
+ self.logger_provider = logger_provider
50
+
51
+
52
+ def setup_otel(cfg: ObtraceConfig) -> OtelProviders:
53
+ resource_attrs: dict[str, Any] = {
54
+ "service.name": cfg.service_name,
55
+ "service.version": cfg.service_version,
56
+ "deployment.environment": cfg.env or "dev",
57
+ "runtime.name": "python",
58
+ }
59
+ if cfg.tenant_id:
60
+ resource_attrs["obtrace.tenant_id"] = cfg.tenant_id
61
+ if cfg.project_id:
62
+ resource_attrs["obtrace.project_id"] = cfg.project_id
63
+ if cfg.app_id:
64
+ resource_attrs["obtrace.app_id"] = cfg.app_id
65
+ if cfg.env:
66
+ resource_attrs["obtrace.env"] = cfg.env
67
+
68
+ resource = Resource.create(resource_attrs)
69
+ base_url = cfg.ingest_base_url.rstrip("/")
70
+ headers = {
71
+ **cfg.default_headers,
72
+ "Authorization": f"Bearer {cfg.api_key}",
73
+ }
74
+
75
+ tracer_provider = TracerProvider(resource=resource)
76
+ tracer_provider.add_span_processor(
77
+ BatchSpanProcessor(
78
+ OTLPSpanExporter(
79
+ endpoint=f"{base_url}/otlp/v1/traces",
80
+ headers=headers,
81
+ timeout=int(cfg.request_timeout_sec),
82
+ )
83
+ )
84
+ )
85
+ try:
86
+ trace.set_tracer_provider(tracer_provider)
87
+ except Exception:
88
+ pass
89
+
90
+ meter_provider = MeterProvider(
91
+ resource=resource,
92
+ metric_readers=[
93
+ PeriodicExportingMetricReader(
94
+ OTLPMetricExporter(
95
+ endpoint=f"{base_url}/otlp/v1/metrics",
96
+ headers=headers,
97
+ timeout=int(cfg.request_timeout_sec),
98
+ ),
99
+ export_interval_millis=60000,
100
+ )
101
+ ],
102
+ )
103
+ try:
104
+ metrics.set_meter_provider(meter_provider)
105
+ except Exception:
106
+ pass
107
+
108
+ logger_provider = LoggerProvider(resource=resource)
109
+ logger_provider.add_log_record_processor(
110
+ BatchLogRecordProcessor(
111
+ OTLPLogExporter(
112
+ endpoint=f"{base_url}/otlp/v1/logs",
113
+ headers=headers,
114
+ timeout=int(cfg.request_timeout_sec),
115
+ )
116
+ )
117
+ )
118
+
119
+ providers = OtelProviders(
120
+ tracer_provider=tracer_provider,
121
+ meter_provider=meter_provider,
122
+ logger_provider=logger_provider,
123
+ )
124
+
125
+ if cfg.auto_instrument_http:
126
+ import threading
127
+ threading.Thread(target=_auto_instrument, daemon=True).start()
128
+
129
+ return providers
130
+
131
+
132
+ def _auto_instrument() -> None:
133
+ import importlib
134
+
135
+ for module_path, class_name in _INSTRUMENTATION_MODULES:
136
+ try:
137
+ mod = importlib.import_module(module_path)
138
+ instrumentor = getattr(mod, class_name)()
139
+ if not instrumentor.is_instrumented_by_opentelemetry:
140
+ instrumentor.instrument()
141
+ except (ImportError, Exception) as e:
142
+ logger.debug("obtrace: skipped %s: %s", module_path, e)
@@ -15,9 +15,9 @@ class ObtraceConfig:
15
15
  app_id: Optional[str] = None
16
16
  env: Optional[str] = None
17
17
  request_timeout_sec: float = 5.0
18
- max_queue_size: int = 1000
19
18
  validate_semantic_metrics: bool = False
20
19
  debug: bool = False
20
+ auto_instrument_http: bool = True
21
21
  default_headers: Dict[str, str] = field(default_factory=dict)
22
22
 
23
23
 
@@ -1,20 +1,30 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obtrace-sdk-python
3
- Version: 1.0.0
3
+ Version: 2.0.0
4
4
  Summary: Obtrace Python SDK
5
5
  Author: Obtrace
6
- Classifier: License :: OSI Approved :: MIT License
6
+ License-Expression: MIT
7
7
  Requires-Python: >=3.10
8
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"
9
+ License-File: LICENSE
10
+ Requires-Dist: opentelemetry-sdk>=1.20.0
11
+ Requires-Dist: opentelemetry-api>=1.20.0
12
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20.0
13
+ Requires-Dist: opentelemetry-instrumentation-requests
14
+ Requires-Dist: opentelemetry-instrumentation-httpx
15
+ Requires-Dist: opentelemetry-instrumentation-urllib
16
+ Requires-Dist: opentelemetry-instrumentation-flask
17
+ Requires-Dist: opentelemetry-instrumentation-fastapi
18
+ Requires-Dist: opentelemetry-instrumentation-django
19
+ Requires-Dist: opentelemetry-instrumentation-logging
20
+ Requires-Dist: opentelemetry-instrumentation-psycopg2
21
+ Requires-Dist: opentelemetry-instrumentation-redis
22
+ Requires-Dist: opentelemetry-instrumentation-sqlalchemy
23
+ Requires-Dist: opentelemetry-instrumentation-celery
24
+ Requires-Dist: opentelemetry-instrumentation-grpc
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0; extra == "dev"
27
+ Dynamic: license-file
18
28
 
19
29
  # obtrace-sdk-python
20
30
 
@@ -34,7 +44,7 @@ SDK is thin/dumb.
34
44
  ## Install
35
45
 
36
46
  ```bash
37
- pip install .
47
+ pip install obtrace-sdk-python
38
48
  ```
39
49
 
40
50
  ## Configuration
@@ -1,10 +1,10 @@
1
+ LICENSE
1
2
  README.md
2
3
  pyproject.toml
3
4
  src/obtrace_sdk/__init__.py
4
5
  src/obtrace_sdk/client.py
5
- src/obtrace_sdk/context.py
6
- src/obtrace_sdk/http.py
7
- src/obtrace_sdk/otlp.py
6
+ src/obtrace_sdk/logging_handler.py
7
+ src/obtrace_sdk/otel_setup.py
8
8
  src/obtrace_sdk/semantic_metrics.py
9
9
  src/obtrace_sdk/types.py
10
10
  src/obtrace_sdk_python.egg-info/PKG-INFO
@@ -13,5 +13,4 @@ src/obtrace_sdk_python.egg-info/dependency_links.txt
13
13
  src/obtrace_sdk_python.egg-info/requires.txt
14
14
  src/obtrace_sdk_python.egg-info/top_level.txt
15
15
  tests/test_client.py
16
- tests/test_context.py
17
16
  tests/test_semantic_metrics.py
@@ -0,0 +1,18 @@
1
+ opentelemetry-sdk>=1.20.0
2
+ opentelemetry-api>=1.20.0
3
+ opentelemetry-exporter-otlp-proto-http>=1.20.0
4
+ opentelemetry-instrumentation-requests
5
+ opentelemetry-instrumentation-httpx
6
+ opentelemetry-instrumentation-urllib
7
+ opentelemetry-instrumentation-flask
8
+ opentelemetry-instrumentation-fastapi
9
+ opentelemetry-instrumentation-django
10
+ opentelemetry-instrumentation-logging
11
+ opentelemetry-instrumentation-psycopg2
12
+ opentelemetry-instrumentation-redis
13
+ opentelemetry-instrumentation-sqlalchemy
14
+ opentelemetry-instrumentation-celery
15
+ opentelemetry-instrumentation-grpc
16
+
17
+ [dev]
18
+ pytest>=8.0
@@ -0,0 +1,91 @@
1
+ import unittest
2
+
3
+ from opentelemetry.sdk._logs import LoggerProvider
4
+ from opentelemetry.sdk.metrics import MeterProvider
5
+ from opentelemetry.sdk.trace import TracerProvider
6
+
7
+ from obtrace_sdk.client import ObtraceClient
8
+ from obtrace_sdk.types import ObtraceConfig, SDKContext
9
+
10
+
11
+ def _make_client(**overrides):
12
+ defaults = dict(
13
+ api_key="devkey",
14
+ ingest_base_url="https://ingest.obtrace.ai",
15
+ service_name="py-test",
16
+ auto_instrument_http=False,
17
+ )
18
+ defaults.update(overrides)
19
+ return ObtraceClient(ObtraceConfig(**defaults))
20
+
21
+
22
+ class ClientTests(unittest.TestCase):
23
+ def test_creates_otel_providers(self):
24
+ c = _make_client()
25
+ self.assertIsInstance(c._providers.tracer_provider, TracerProvider)
26
+ self.assertIsInstance(c._providers.meter_provider, MeterProvider)
27
+ self.assertIsInstance(c._providers.logger_provider, LoggerProvider)
28
+ c.shutdown()
29
+
30
+ def test_log_does_not_raise(self):
31
+ c = _make_client()
32
+ c.log("info", "hello")
33
+ c.log("error", "bad thing", SDKContext(trace_id="a" * 32, span_id="b" * 16))
34
+ c.shutdown()
35
+
36
+ def test_metric_does_not_raise(self):
37
+ c = _make_client()
38
+ c.metric("m", 1.0)
39
+ c.metric("m2", 42.0, unit="ms", context=SDKContext(attrs={"env": "test"}))
40
+ c.shutdown()
41
+
42
+ def test_span_returns_trace_and_span_id(self):
43
+ c = _make_client()
44
+ result = c.span("test-span", attrs={"key": "value"})
45
+ self.assertIn("trace_id", result)
46
+ self.assertIn("span_id", result)
47
+ self.assertEqual(len(result["trace_id"]), 32)
48
+ self.assertEqual(len(result["span_id"]), 16)
49
+ c.shutdown()
50
+
51
+ def test_span_error_status(self):
52
+ c = _make_client()
53
+ result = c.span("error-span", status_code=500, status_message="internal error")
54
+ self.assertIn("trace_id", result)
55
+ c.shutdown()
56
+
57
+ def test_capture_error(self):
58
+ c = _make_client()
59
+ c.capture_error(ValueError("test error"), attrs={"component": "db"})
60
+ c.shutdown()
61
+
62
+ def test_context_manager(self):
63
+ with _make_client() as c:
64
+ c.log("info", "inside context")
65
+
66
+ def test_start_span_context_manager(self):
67
+ c = _make_client()
68
+ with c.start_span("my-operation", attrs={"step": "1"}) as span:
69
+ span.set_attribute("result", "ok")
70
+ c.shutdown()
71
+
72
+ def test_inject_propagation(self):
73
+ c = _make_client()
74
+ headers = c.inject_propagation(session_id="sess-123")
75
+ self.assertIn("x-obtrace-session-id", headers)
76
+ self.assertEqual(headers["x-obtrace-session-id"], "sess-123")
77
+ c.shutdown()
78
+
79
+ def test_flush_does_not_raise(self):
80
+ c = _make_client()
81
+ c.log("info", "pre-flush")
82
+ c.flush()
83
+ c.shutdown()
84
+
85
+ def test_validation_error_on_missing_fields(self):
86
+ with self.assertRaises(ValueError):
87
+ ObtraceClient(ObtraceConfig(api_key="", ingest_base_url="http://x", service_name="svc"))
88
+
89
+
90
+ if __name__ == "__main__":
91
+ unittest.main()
@@ -1,25 +0,0 @@
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"]
@@ -1,12 +0,0 @@
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
- ]
@@ -1,159 +0,0 @@
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}")
@@ -1,31 +0,0 @@
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
@@ -1,159 +0,0 @@
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
@@ -1,161 +0,0 @@
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
- }
@@ -1,13 +0,0 @@
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
@@ -1,32 +0,0 @@
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()
@@ -1,21 +0,0 @@
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()