ko-observability 0.2.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.
- ko_observability-0.2.0/.gitignore +34 -0
- ko_observability-0.2.0/CHANGELOG.md +22 -0
- ko_observability-0.2.0/PKG-INFO +110 -0
- ko_observability-0.2.0/README.md +72 -0
- ko_observability-0.2.0/pyproject.toml +69 -0
- ko_observability-0.2.0/src/ko_observability/__init__.py +15 -0
- ko_observability-0.2.0/src/ko_observability/client.py +67 -0
- ko_observability-0.2.0/src/ko_observability/exporters.py +208 -0
- ko_observability-0.2.0/src/ko_observability/otel.py +262 -0
- ko_observability-0.2.0/src/ko_observability/otlp_config.py +88 -0
- ko_observability-0.2.0/src/ko_observability/py.typed +0 -0
- ko_observability-0.2.0/src/ko_observability/query.py +436 -0
- ko_observability-0.2.0/src/ko_observability/structlog_bridge.py +115 -0
- ko_observability-0.2.0/tests/conftest.py +7 -0
- ko_observability-0.2.0/tests/test_client.py +51 -0
- ko_observability-0.2.0/tests/test_exporters.py +349 -0
- ko_observability-0.2.0/tests/test_instrumentation.py +152 -0
- ko_observability-0.2.0/tests/test_otel.py +314 -0
- ko_observability-0.2.0/tests/test_otlp_config.py +123 -0
- ko_observability-0.2.0/tests/test_query.py +333 -0
- ko_observability-0.2.0/tests/test_span_processor.py +84 -0
- ko_observability-0.2.0/tests/test_structlog_bridge.py +85 -0
- ko_observability-0.2.0/uv.lock +951 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
.pytest_cache/
|
|
4
|
+
.mypy_cache/
|
|
5
|
+
.ruff_cache/
|
|
6
|
+
dist/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
|
|
11
|
+
node_modules/
|
|
12
|
+
package-lock.json
|
|
13
|
+
|
|
14
|
+
.env
|
|
15
|
+
.env.otel
|
|
16
|
+
.env.otel-platform
|
|
17
|
+
deploy/tls/*.pem
|
|
18
|
+
deploy/tls/*.key
|
|
19
|
+
!deploy/tls/.gitignore
|
|
20
|
+
|
|
21
|
+
.DS_Store
|
|
22
|
+
.idea/
|
|
23
|
+
.vscode/
|
|
24
|
+
|
|
25
|
+
.claude/settings.local.json
|
|
26
|
+
.codegraph/
|
|
27
|
+
|
|
28
|
+
# TELEMETRY_DESTINATION=file default output + parity/Go-in-Docker caches
|
|
29
|
+
telemetry/
|
|
30
|
+
parity/file_mode/.out/
|
|
31
|
+
.gocache/
|
|
32
|
+
|
|
33
|
+
# /graphify skill output (knowledge-graph build artifacts, session-local)
|
|
34
|
+
graphify-out/
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0](https://github.com/Komus-ai/nexora-observability/compare/python-v0.1.0...python-v0.2.0) (2026-09-18)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* telemetry is written to ./telemetry/*.jsonl by default instead of being exported to a Collector over OTLP/gRPC. Set TELEMETRY_DESTINATION=otlp to restore the previous behavior.
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* default telemetry destination to local OTLP/JSON files ([223b5cf](https://github.com/Komus-ai/nexora-observability/commit/223b5cfc92bb31d9a3b255a4eb4c6e2dd69f1af2))
|
|
13
|
+
* **go:** add Go SDK core + full-repo audit fixes (Python/JS/deploy/CI/docs) ([#5](https://github.com/Komus-ai/nexora-observability/issues/5)) ([61433d9](https://github.com/Komus-ai/nexora-observability/commit/61433d904dada98757e900b13049d801f5188085))
|
|
14
|
+
* initial nexora-observability package (Python + JS/TS OTel bootstrap) ([129c2fa](https://github.com/Komus-ai/nexora-observability/commit/129c2fa69657b7de61cf652a7f120ce80af7f478))
|
|
15
|
+
* **python:** add optional read-only query client for own telemetry ([b92a4bb](https://github.com/Komus-ai/nexora-observability/commit/b92a4bb23568cab30e4ee7529549d7fdcfc51104))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **python:** fix mypy type errors, add PEP 561 marker, cover init_otel ([85e2a19](https://github.com/Komus-ai/nexora-observability/commit/85e2a19b3d1697d0a25b853260994c26560072d2))
|
|
21
|
+
* **query:** correct status field path/value from live-cluster verification ([8e5861c](https://github.com/Komus-ai/nexora-observability/commit/8e5861c9d874f97caf6c49d4317fc4e978dfede9))
|
|
22
|
+
* release-readiness fixes for first 0.2.0 publish ([#8](https://github.com/Komus-ai/nexora-observability/issues/8)) ([d7dda1f](https://github.com/Komus-ai/nexora-observability/commit/d7dda1f79ab50edc3fd29b2b71348c1956c06634))
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ko-observability
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Org-wide OpenTelemetry bootstrap for Nexora Python services
|
|
5
|
+
Project-URL: Repository, https://github.com/Komus-ai/nexora-observability
|
|
6
|
+
Project-URL: Documentation, https://github.com/Komus-ai/nexora-observability/tree/main/docs
|
|
7
|
+
Author-email: Komus-ai <dev@cinco.ai>
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: observability,opentelemetry,otlp,structlog
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: opentelemetry-api>=1.27.0
|
|
17
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-common>=1.27.0
|
|
18
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.27.0
|
|
19
|
+
Requires-Dist: opentelemetry-sdk>=1.27.0
|
|
20
|
+
Requires-Dist: protobuf<8.0,>=5.0
|
|
21
|
+
Requires-Dist: structlog>=24.4.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: mypy>=1.13.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: opentelemetry-sdk>=1.27.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.8.0; extra == 'dev'
|
|
28
|
+
Provides-Extra: elasticsearch
|
|
29
|
+
Requires-Dist: opentelemetry-instrumentation-elasticsearch>=0.48b0; extra == 'elasticsearch'
|
|
30
|
+
Provides-Extra: fastapi
|
|
31
|
+
Requires-Dist: fastapi>=0.115.0; extra == 'fastapi'
|
|
32
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi>=0.48b0; extra == 'fastapi'
|
|
33
|
+
Provides-Extra: httpx
|
|
34
|
+
Requires-Dist: opentelemetry-instrumentation-httpx>=0.48b0; extra == 'httpx'
|
|
35
|
+
Provides-Extra: query
|
|
36
|
+
Requires-Dist: httpx>=0.27.0; extra == 'query'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# ko-observability (Python)
|
|
40
|
+
|
|
41
|
+
Org-wide OpenTelemetry bootstrap for Nexora Python services: one-call, idempotent
|
|
42
|
+
`init_otel` for process startup, lazy `get_tracer` / `get_meter` accessors for library code
|
|
43
|
+
that doesn't own process lifecycle, and a structlog processor (`otel_bridge` /
|
|
44
|
+
`make_otel_bridge`) that stamps each log line with the active span's `trace_id`/`span_id`.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install ko-observability
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Optional extras, install alongside the base package as needed:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Auto-instrument FastAPI request/response spans
|
|
56
|
+
pip install "ko-observability[fastapi]"
|
|
57
|
+
|
|
58
|
+
# Auto-instrument outbound httpx calls
|
|
59
|
+
pip install "ko-observability[httpx]"
|
|
60
|
+
|
|
61
|
+
# Auto-instrument the Elasticsearch client
|
|
62
|
+
pip install "ko-observability[elasticsearch]"
|
|
63
|
+
|
|
64
|
+
# Read your own service's telemetry back out (admin-panel style queries)
|
|
65
|
+
pip install "ko-observability[query]"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from ko_observability import init_otel, get_tracer
|
|
72
|
+
|
|
73
|
+
init_otel(service_name="my-service", service_namespace="nexora")
|
|
74
|
+
|
|
75
|
+
tracer = get_tracer(__name__)
|
|
76
|
+
with tracer.start_as_current_span("do-work"):
|
|
77
|
+
...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Call `init_otel` exactly once at process startup. It's safe to call again (or to let library
|
|
81
|
+
code fall back to `get_tracer`/`get_meter` before it runs) — both paths are no-ops on top of an
|
|
82
|
+
already-configured provider. `OTEL_SDK_DISABLED=true` turns the whole thing into a no-op, and a
|
|
83
|
+
misconfigured exporter degrades to "telemetry doesn't work" rather than crashing the host process.
|
|
84
|
+
|
|
85
|
+
### Where does it go?
|
|
86
|
+
|
|
87
|
+
| `TELEMETRY_DESTINATION` | Output |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `file` (default) | `$TELEMETRY_FILE_DIR/{traces,metrics,logs}.jsonl` (default `./telemetry`), one OTLP/JSON request per line |
|
|
90
|
+
| `otlp` | OTLP/gRPC to `OTEL_EXPORTER_OTLP_ENDPOINT` (default `http://localhost:4317`) |
|
|
91
|
+
|
|
92
|
+
Full contract: [docs/otel.md → Local file mode](../../docs/otel.md#local-file-mode-the-default).
|
|
93
|
+
|
|
94
|
+
## Reading telemetry back (`query` extra)
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from ko_observability.query import get_query_client
|
|
98
|
+
|
|
99
|
+
client = get_query_client()
|
|
100
|
+
traces = client.list_traces(limit=20)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Scoped to the caller's own `service_name` by default. See [`docs/query.md`](../../docs/query.md).
|
|
104
|
+
|
|
105
|
+
## Docs
|
|
106
|
+
|
|
107
|
+
Full documentation — env vars, the logs/metrics pipelines, semantic conventions, and the
|
|
108
|
+
Collector deployment this SDK ships telemetry to — lives in the repository:
|
|
109
|
+
|
|
110
|
+
https://github.com/Komus-ai/nexora-observability/tree/main/docs
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# ko-observability (Python)
|
|
2
|
+
|
|
3
|
+
Org-wide OpenTelemetry bootstrap for Nexora Python services: one-call, idempotent
|
|
4
|
+
`init_otel` for process startup, lazy `get_tracer` / `get_meter` accessors for library code
|
|
5
|
+
that doesn't own process lifecycle, and a structlog processor (`otel_bridge` /
|
|
6
|
+
`make_otel_bridge`) that stamps each log line with the active span's `trace_id`/`span_id`.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install ko-observability
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Optional extras, install alongside the base package as needed:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# Auto-instrument FastAPI request/response spans
|
|
18
|
+
pip install "ko-observability[fastapi]"
|
|
19
|
+
|
|
20
|
+
# Auto-instrument outbound httpx calls
|
|
21
|
+
pip install "ko-observability[httpx]"
|
|
22
|
+
|
|
23
|
+
# Auto-instrument the Elasticsearch client
|
|
24
|
+
pip install "ko-observability[elasticsearch]"
|
|
25
|
+
|
|
26
|
+
# Read your own service's telemetry back out (admin-panel style queries)
|
|
27
|
+
pip install "ko-observability[query]"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from ko_observability import init_otel, get_tracer
|
|
34
|
+
|
|
35
|
+
init_otel(service_name="my-service", service_namespace="nexora")
|
|
36
|
+
|
|
37
|
+
tracer = get_tracer(__name__)
|
|
38
|
+
with tracer.start_as_current_span("do-work"):
|
|
39
|
+
...
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Call `init_otel` exactly once at process startup. It's safe to call again (or to let library
|
|
43
|
+
code fall back to `get_tracer`/`get_meter` before it runs) — both paths are no-ops on top of an
|
|
44
|
+
already-configured provider. `OTEL_SDK_DISABLED=true` turns the whole thing into a no-op, and a
|
|
45
|
+
misconfigured exporter degrades to "telemetry doesn't work" rather than crashing the host process.
|
|
46
|
+
|
|
47
|
+
### Where does it go?
|
|
48
|
+
|
|
49
|
+
| `TELEMETRY_DESTINATION` | Output |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `file` (default) | `$TELEMETRY_FILE_DIR/{traces,metrics,logs}.jsonl` (default `./telemetry`), one OTLP/JSON request per line |
|
|
52
|
+
| `otlp` | OTLP/gRPC to `OTEL_EXPORTER_OTLP_ENDPOINT` (default `http://localhost:4317`) |
|
|
53
|
+
|
|
54
|
+
Full contract: [docs/otel.md → Local file mode](../../docs/otel.md#local-file-mode-the-default).
|
|
55
|
+
|
|
56
|
+
## Reading telemetry back (`query` extra)
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from ko_observability.query import get_query_client
|
|
60
|
+
|
|
61
|
+
client = get_query_client()
|
|
62
|
+
traces = client.list_traces(limit=20)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Scoped to the caller's own `service_name` by default. See [`docs/query.md`](../../docs/query.md).
|
|
66
|
+
|
|
67
|
+
## Docs
|
|
68
|
+
|
|
69
|
+
Full documentation — env vars, the logs/metrics pipelines, semantic conventions, and the
|
|
70
|
+
Collector deployment this SDK ships telemetry to — lives in the repository:
|
|
71
|
+
|
|
72
|
+
https://github.com/Komus-ai/nexora-observability/tree/main/docs
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ko-observability"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Org-wide OpenTelemetry bootstrap for Nexora Python services"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Komus-ai", email = "dev@cinco.ai" }]
|
|
13
|
+
keywords = ["opentelemetry", "observability", "otlp", "structlog"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"opentelemetry-api>=1.27.0",
|
|
23
|
+
"opentelemetry-sdk>=1.27.0",
|
|
24
|
+
"opentelemetry-exporter-otlp-proto-grpc>=1.27.0",
|
|
25
|
+
"opentelemetry-exporter-otlp-proto-common>=1.27.0",
|
|
26
|
+
"protobuf>=5.0,<8.0",
|
|
27
|
+
"structlog>=24.4.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
fastapi = [
|
|
32
|
+
"opentelemetry-instrumentation-fastapi>=0.48b0",
|
|
33
|
+
"fastapi>=0.115.0",
|
|
34
|
+
]
|
|
35
|
+
httpx = ["opentelemetry-instrumentation-httpx>=0.48b0"]
|
|
36
|
+
elasticsearch = ["opentelemetry-instrumentation-elasticsearch>=0.48b0"]
|
|
37
|
+
query = ["httpx>=0.27.0"]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=8.3.0",
|
|
40
|
+
"opentelemetry-sdk>=1.27.0",
|
|
41
|
+
"httpx>=0.27.0",
|
|
42
|
+
"ruff>=0.8.0",
|
|
43
|
+
"mypy>=1.13.0",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Repository = "https://github.com/Komus-ai/nexora-observability"
|
|
48
|
+
Documentation = "https://github.com/Komus-ai/nexora-observability/tree/main/docs"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/ko_observability"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
55
|
+
pythonpath = ["src"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff]
|
|
58
|
+
target-version = "py312"
|
|
59
|
+
line-length = 100
|
|
60
|
+
src = ["src", "tests"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
64
|
+
|
|
65
|
+
[tool.mypy]
|
|
66
|
+
python_version = "3.12"
|
|
67
|
+
packages = ["ko_observability"]
|
|
68
|
+
mypy_path = "src"
|
|
69
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Nexora org-wide OpenTelemetry libraries."""
|
|
2
|
+
|
|
3
|
+
from ko_observability.client import get_meter, get_tracer
|
|
4
|
+
from ko_observability.otel import init_otel
|
|
5
|
+
from ko_observability.structlog_bridge import make_otel_bridge, otel_bridge
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"init_otel",
|
|
9
|
+
"get_tracer",
|
|
10
|
+
"get_meter",
|
|
11
|
+
"otel_bridge",
|
|
12
|
+
"make_otel_bridge",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Lazy OTel client helpers for library and application code.
|
|
2
|
+
|
|
3
|
+
Use these when you want a Tracer/Meter without requiring the host process to
|
|
4
|
+
have called ``init_otel`` first (e.g. inside a reusable library). Each
|
|
5
|
+
function bootstraps a minimal provider on first use if -- and only if -- no
|
|
6
|
+
provider has been registered yet by the host process.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
|
|
14
|
+
from opentelemetry import metrics as _otel_metrics
|
|
15
|
+
from opentelemetry import trace
|
|
16
|
+
|
|
17
|
+
from ko_observability.exporters import make_span_exporter
|
|
18
|
+
from ko_observability.otlp_config import sdk_disabled
|
|
19
|
+
|
|
20
|
+
_init_lock = threading.Lock()
|
|
21
|
+
_initialized = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_tracer(name: str, version: str | None = None):
|
|
25
|
+
"""Return a Tracer, lazily bootstrapping a minimal provider if needed."""
|
|
26
|
+
_ensure_trace_provider()
|
|
27
|
+
return trace.get_tracer(name, version)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_meter(name: str, version: str | None = None) -> _otel_metrics.Meter:
|
|
31
|
+
"""Return a Meter (no-op if metrics provider is not configured)."""
|
|
32
|
+
return _otel_metrics.get_meter(name, version or "")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _ensure_trace_provider() -> None:
|
|
36
|
+
global _initialized
|
|
37
|
+
if _initialized:
|
|
38
|
+
return
|
|
39
|
+
with _init_lock:
|
|
40
|
+
if _initialized:
|
|
41
|
+
return
|
|
42
|
+
if sdk_disabled():
|
|
43
|
+
_initialized = True
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
provider = trace.get_tracer_provider()
|
|
47
|
+
if "NoOp" not in type(provider).__name__ and "Proxy" not in type(provider).__name__:
|
|
48
|
+
_initialized = True
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
from opentelemetry.sdk.resources import Resource
|
|
53
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
54
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
55
|
+
|
|
56
|
+
resource = Resource.create(
|
|
57
|
+
{
|
|
58
|
+
"service.name": os.getenv("OTEL_SERVICE_NAME", "nexora-service"),
|
|
59
|
+
"service.namespace": os.getenv("OTEL_SERVICE_NAMESPACE", "nexora"),
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
tp = TracerProvider(resource=resource)
|
|
63
|
+
tp.add_span_processor(BatchSpanProcessor(make_span_exporter()))
|
|
64
|
+
trace.set_tracer_provider(tp)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
_initialized = True
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Exporter factories: local JSONL files (default) or OTLP/gRPC, per TELEMETRY_DESTINATION.
|
|
2
|
+
|
|
3
|
+
File exporters write one OTLP/JSON ``Export*ServiceRequest`` per line -- the
|
|
4
|
+
same encoding the Collector's ``otlpjsonfile`` receiver replays -- with
|
|
5
|
+
``traceId``/``spanId``/``parentSpanId`` rewritten from protobuf-JSON base64
|
|
6
|
+
to the spec's hex form. Append-only, one ``write()`` per batch, mutex-guarded.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import binascii
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from opentelemetry.sdk._logs.export import LogRecordExporter, LogRecordExportResult
|
|
21
|
+
from opentelemetry.sdk.metrics.export import MetricExporter, MetricExportResult, MetricsData
|
|
22
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
23
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
24
|
+
|
|
25
|
+
from ko_observability.otlp_config import (
|
|
26
|
+
otlp_endpoint,
|
|
27
|
+
otlp_exporter_insecure,
|
|
28
|
+
telemetry_destination,
|
|
29
|
+
telemetry_file_dir,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
TRACES_FILE = "traces.jsonl"
|
|
35
|
+
METRICS_FILE = "metrics.jsonl"
|
|
36
|
+
LOGS_FILE = "logs.jsonl"
|
|
37
|
+
|
|
38
|
+
_ID_KEYS = frozenset({"traceId", "spanId", "parentSpanId"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _b64_to_hex(value: str) -> str:
|
|
42
|
+
try:
|
|
43
|
+
return base64.b64decode(value, validate=True).hex()
|
|
44
|
+
except (binascii.Error, ValueError):
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _hex_ids(obj: Any) -> Any:
|
|
49
|
+
if isinstance(obj, dict):
|
|
50
|
+
return {
|
|
51
|
+
k: (_b64_to_hex(v) if k in _ID_KEYS and isinstance(v, str) else _hex_ids(v))
|
|
52
|
+
for k, v in obj.items()
|
|
53
|
+
}
|
|
54
|
+
if isinstance(obj, list):
|
|
55
|
+
return [_hex_ids(v) for v in obj]
|
|
56
|
+
return obj
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _to_otlp_json(message: Any) -> dict[str, Any]:
|
|
60
|
+
from google.protobuf.json_format import MessageToDict
|
|
61
|
+
|
|
62
|
+
return _hex_ids(MessageToDict(message, use_integers_for_enums=True))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _JsonlWriter:
|
|
66
|
+
def __init__(self, path: str) -> None:
|
|
67
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
68
|
+
self._fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
69
|
+
self._lock = threading.Lock()
|
|
70
|
+
|
|
71
|
+
def write_line(self, obj: dict[str, Any]) -> None:
|
|
72
|
+
data = (json.dumps(obj, separators=(",", ":")) + "\n").encode("utf-8")
|
|
73
|
+
with self._lock:
|
|
74
|
+
if self._fd < 0:
|
|
75
|
+
raise OSError("telemetry file closed")
|
|
76
|
+
view = memoryview(data)
|
|
77
|
+
written = 0
|
|
78
|
+
try:
|
|
79
|
+
while view:
|
|
80
|
+
n = os.write(self._fd, view)
|
|
81
|
+
written += n
|
|
82
|
+
view = view[n:]
|
|
83
|
+
except OSError:
|
|
84
|
+
# A partial write before the failure leaves a fragment (no
|
|
85
|
+
# trailing newline) on disk -- but only if this call made any
|
|
86
|
+
# progress at all; a failure on the very first byte leaves the
|
|
87
|
+
# file exactly as the last successful write_line left it (already
|
|
88
|
+
# newline-terminated), so no recovery byte is needed there.
|
|
89
|
+
# Left alone, the next successful export would append onto the
|
|
90
|
+
# fragment and produce one unparseable JSONL line, corrupting
|
|
91
|
+
# previously-good data rather than just the failed batch.
|
|
92
|
+
# Best-effort: terminate the fragment with a newline so line
|
|
93
|
+
# framing survives, then propagate the original error (never
|
|
94
|
+
# crash the host).
|
|
95
|
+
if written > 0:
|
|
96
|
+
try:
|
|
97
|
+
os.write(self._fd, b"\n")
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
def close(self) -> None:
|
|
103
|
+
with self._lock:
|
|
104
|
+
if self._fd >= 0:
|
|
105
|
+
os.close(self._fd)
|
|
106
|
+
self._fd = -1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _default_path(name: str) -> str:
|
|
110
|
+
return os.path.join(telemetry_file_dir(), name)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class FileSpanExporter(SpanExporter):
|
|
114
|
+
def __init__(self, path: str | None = None) -> None:
|
|
115
|
+
self._writer = _JsonlWriter(path or _default_path(TRACES_FILE))
|
|
116
|
+
|
|
117
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
118
|
+
try:
|
|
119
|
+
from opentelemetry.exporter.otlp.proto.common.trace_encoder import encode_spans
|
|
120
|
+
|
|
121
|
+
self._writer.write_line(_to_otlp_json(encode_spans(spans)))
|
|
122
|
+
return SpanExportResult.SUCCESS
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
_logger.warning("telemetry.file_export_failed: traces: %s", exc)
|
|
125
|
+
return SpanExportResult.FAILURE
|
|
126
|
+
|
|
127
|
+
def shutdown(self) -> None:
|
|
128
|
+
self._writer.close()
|
|
129
|
+
|
|
130
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class FileLogExporter(LogRecordExporter):
|
|
135
|
+
def __init__(self, path: str | None = None) -> None:
|
|
136
|
+
self._writer = _JsonlWriter(path or _default_path(LOGS_FILE))
|
|
137
|
+
|
|
138
|
+
def export(self, batch: Sequence[Any]) -> LogRecordExportResult:
|
|
139
|
+
try:
|
|
140
|
+
from opentelemetry.exporter.otlp.proto.common._log_encoder import encode_logs
|
|
141
|
+
|
|
142
|
+
self._writer.write_line(_to_otlp_json(encode_logs(batch)))
|
|
143
|
+
return LogRecordExportResult.SUCCESS
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
_logger.warning("telemetry.file_export_failed: logs: %s", exc)
|
|
146
|
+
return LogRecordExportResult.FAILURE
|
|
147
|
+
|
|
148
|
+
def shutdown(self) -> None:
|
|
149
|
+
self._writer.close()
|
|
150
|
+
|
|
151
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class FileMetricExporter(MetricExporter):
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
path: str | None = None,
|
|
159
|
+
preferred_temporality: dict[type, Any] | None = None,
|
|
160
|
+
preferred_aggregation: dict[type, Any] | None = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
super().__init__(
|
|
163
|
+
preferred_temporality=preferred_temporality,
|
|
164
|
+
preferred_aggregation=preferred_aggregation,
|
|
165
|
+
)
|
|
166
|
+
self._writer = _JsonlWriter(path or _default_path(METRICS_FILE))
|
|
167
|
+
|
|
168
|
+
def export(
|
|
169
|
+
self, metrics_data: MetricsData, timeout_millis: float = 10_000, **kwargs: Any
|
|
170
|
+
) -> MetricExportResult:
|
|
171
|
+
try:
|
|
172
|
+
from opentelemetry.exporter.otlp.proto.common.metrics_encoder import encode_metrics
|
|
173
|
+
|
|
174
|
+
self._writer.write_line(_to_otlp_json(encode_metrics(metrics_data)))
|
|
175
|
+
return MetricExportResult.SUCCESS
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
_logger.warning("telemetry.file_export_failed: metrics: %s", exc)
|
|
178
|
+
return MetricExportResult.FAILURE
|
|
179
|
+
|
|
180
|
+
def force_flush(self, timeout_millis: float = 10_000) -> bool:
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
def shutdown(self, timeout_millis: float = 30_000, **kwargs: Any) -> None:
|
|
184
|
+
self._writer.close()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def make_span_exporter() -> SpanExporter:
|
|
188
|
+
if telemetry_destination() == "file":
|
|
189
|
+
return FileSpanExporter()
|
|
190
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
|
191
|
+
|
|
192
|
+
return OTLPSpanExporter(endpoint=otlp_endpoint(), insecure=otlp_exporter_insecure())
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def make_log_exporter() -> LogRecordExporter:
|
|
196
|
+
if telemetry_destination() == "file":
|
|
197
|
+
return FileLogExporter()
|
|
198
|
+
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
|
|
199
|
+
|
|
200
|
+
return OTLPLogExporter(endpoint=otlp_endpoint(), insecure=otlp_exporter_insecure())
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def make_metric_exporter() -> MetricExporter:
|
|
204
|
+
if telemetry_destination() == "file":
|
|
205
|
+
return FileMetricExporter()
|
|
206
|
+
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
|
207
|
+
|
|
208
|
+
return OTLPMetricExporter(endpoint=otlp_endpoint(), insecure=otlp_exporter_insecure())
|