servicedna 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- servicedna-0.1.0/.gitignore +4 -0
- servicedna-0.1.0/PKG-INFO +89 -0
- servicedna-0.1.0/README.md +53 -0
- servicedna-0.1.0/pyproject.toml +51 -0
- servicedna-0.1.0/src/servicedna/__init__.py +40 -0
- servicedna-0.1.0/src/servicedna/bodies.py +127 -0
- servicedna-0.1.0/src/servicedna/config.py +88 -0
- servicedna-0.1.0/src/servicedna/distro.py +61 -0
- servicedna-0.1.0/src/servicedna/grpc_capture.py +127 -0
- servicedna-0.1.0/src/servicedna/heartbeat.py +84 -0
- servicedna-0.1.0/src/servicedna/run.py +32 -0
- servicedna-0.1.0/tests/test_capture.py +118 -0
- servicedna-0.1.0/tests/test_config.py +83 -0
- servicedna-0.1.0/tests/test_grpc_capture.py +100 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: servicedna
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Connect a Python service to ServiceDNA: traces, self-registration and health heartbeats with two environment variables.
|
|
5
|
+
Project-URL: Source, https://github.com/varuns2903/servicedna/tree/main/sdks/python
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: opentelemetry-api~=1.45
|
|
9
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http~=1.45
|
|
10
|
+
Requires-Dist: opentelemetry-instrumentation-aiohttp-client==0.66b0
|
|
11
|
+
Requires-Dist: opentelemetry-instrumentation-asgi==0.66b0
|
|
12
|
+
Requires-Dist: opentelemetry-instrumentation-confluent-kafka==0.66b0
|
|
13
|
+
Requires-Dist: opentelemetry-instrumentation-django==0.66b0
|
|
14
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi==0.66b0
|
|
15
|
+
Requires-Dist: opentelemetry-instrumentation-flask==0.66b0
|
|
16
|
+
Requires-Dist: opentelemetry-instrumentation-grpc==0.66b0
|
|
17
|
+
Requires-Dist: opentelemetry-instrumentation-httpx==0.66b0
|
|
18
|
+
Requires-Dist: opentelemetry-instrumentation-kafka-python==0.66b0
|
|
19
|
+
Requires-Dist: opentelemetry-instrumentation-psycopg2==0.66b0
|
|
20
|
+
Requires-Dist: opentelemetry-instrumentation-redis==0.66b0
|
|
21
|
+
Requires-Dist: opentelemetry-instrumentation-requests==0.66b0
|
|
22
|
+
Requires-Dist: opentelemetry-instrumentation-sqlalchemy==0.66b0
|
|
23
|
+
Requires-Dist: opentelemetry-instrumentation-urllib3==0.66b0
|
|
24
|
+
Requires-Dist: opentelemetry-instrumentation-wsgi==0.66b0
|
|
25
|
+
Requires-Dist: opentelemetry-instrumentation==0.66b0
|
|
26
|
+
Requires-Dist: opentelemetry-sdk~=1.45
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: fastapi; extra == 'test'
|
|
29
|
+
Requires-Dist: grpcio; extra == 'test'
|
|
30
|
+
Requires-Dist: grpcio-health-checking; extra == 'test'
|
|
31
|
+
Requires-Dist: httpx; extra == 'test'
|
|
32
|
+
Requires-Dist: pytest; extra == 'test'
|
|
33
|
+
Requires-Dist: pytest-asyncio; extra == 'test'
|
|
34
|
+
Requires-Dist: uvicorn; extra == 'test'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# servicedna (Python)
|
|
38
|
+
|
|
39
|
+
Connect a Python service to [ServiceDNA](https://github.com/varuns2903/servicedna) with one
|
|
40
|
+
dependency and two environment variables. You get:
|
|
41
|
+
|
|
42
|
+
- **Distributed traces** for FastAPI, Flask, Django, ASGI/WSGI, requests, httpx, urllib3,
|
|
43
|
+
aiohttp, gRPC, SQLAlchemy, psycopg2, Redis and Kafka (OpenTelemetry auto-instrumentation —
|
|
44
|
+
only libraries you actually use are instrumented)
|
|
45
|
+
- **Self-registration** — the service appears in ServiceDNA on first start
|
|
46
|
+
- **Health heartbeats** — the service's own `/health` is checked and reported every 15 s
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install servicedna
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Run
|
|
55
|
+
|
|
56
|
+
Prefix your usual command with `servicedna-run`:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
SERVICEDNA_URL=https://servicedna.example.com \
|
|
60
|
+
SERVICEDNA_KEY=sdna_ik_... \
|
|
61
|
+
servicedna-run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Or start it from code, first thing in your entry point:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import servicedna
|
|
68
|
+
servicedna.start()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`servicedna-run` is preferred: it instruments libraries before your code imports them. Without
|
|
72
|
+
`SERVICEDNA_URL` and `SERVICEDNA_KEY` both do nothing, so the same build runs anywhere.
|
|
73
|
+
|
|
74
|
+
## Configuration
|
|
75
|
+
|
|
76
|
+
| Variable | Default | |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `SERVICEDNA_URL` | — | ServiceDNA base URL (required) |
|
|
79
|
+
| `SERVICEDNA_KEY` | — | Organization ingestion key, from Settings → Integrations (required) |
|
|
80
|
+
| `SERVICEDNA_ENV` | — | Environment (`dev`, `staging`, `prod`…); each is tracked separately |
|
|
81
|
+
| `OTEL_SERVICE_NAME` / `SERVICEDNA_SERVICE` | working directory name | Service name |
|
|
82
|
+
| `SERVICEDNA_VERSION` | — | |
|
|
83
|
+
| `SERVICEDNA_HEALTH_URL` | — | Health URL **ServiceDNA** can reach, so it can also probe the service itself |
|
|
84
|
+
| `SERVICEDNA_HEALTH_PATH` | `/health` | Path the heartbeat checks on `http://127.0.0.1:$PORT` |
|
|
85
|
+
| `SERVICEDNA_LOCAL_HEALTH_URL` | from `PORT` + path | Full URL the heartbeat checks, if not on `PORT` |
|
|
86
|
+
| `SERVICEDNA_HEARTBEAT_MS` | `15000` | `0` disables heartbeats |
|
|
87
|
+
| `SERVICEDNA_DEGRADED_MS` | `2000` | Health response slower than this is reported DEGRADED |
|
|
88
|
+
|
|
89
|
+
Standard `OTEL_*` variables still apply and win over these defaults.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# servicedna (Python)
|
|
2
|
+
|
|
3
|
+
Connect a Python service to [ServiceDNA](https://github.com/varuns2903/servicedna) with one
|
|
4
|
+
dependency and two environment variables. You get:
|
|
5
|
+
|
|
6
|
+
- **Distributed traces** for FastAPI, Flask, Django, ASGI/WSGI, requests, httpx, urllib3,
|
|
7
|
+
aiohttp, gRPC, SQLAlchemy, psycopg2, Redis and Kafka (OpenTelemetry auto-instrumentation —
|
|
8
|
+
only libraries you actually use are instrumented)
|
|
9
|
+
- **Self-registration** — the service appears in ServiceDNA on first start
|
|
10
|
+
- **Health heartbeats** — the service's own `/health` is checked and reported every 15 s
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install servicedna
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Run
|
|
19
|
+
|
|
20
|
+
Prefix your usual command with `servicedna-run`:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
SERVICEDNA_URL=https://servicedna.example.com \
|
|
24
|
+
SERVICEDNA_KEY=sdna_ik_... \
|
|
25
|
+
servicedna-run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or start it from code, first thing in your entry point:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import servicedna
|
|
32
|
+
servicedna.start()
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`servicedna-run` is preferred: it instruments libraries before your code imports them. Without
|
|
36
|
+
`SERVICEDNA_URL` and `SERVICEDNA_KEY` both do nothing, so the same build runs anywhere.
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
| Variable | Default | |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `SERVICEDNA_URL` | — | ServiceDNA base URL (required) |
|
|
43
|
+
| `SERVICEDNA_KEY` | — | Organization ingestion key, from Settings → Integrations (required) |
|
|
44
|
+
| `SERVICEDNA_ENV` | — | Environment (`dev`, `staging`, `prod`…); each is tracked separately |
|
|
45
|
+
| `OTEL_SERVICE_NAME` / `SERVICEDNA_SERVICE` | working directory name | Service name |
|
|
46
|
+
| `SERVICEDNA_VERSION` | — | |
|
|
47
|
+
| `SERVICEDNA_HEALTH_URL` | — | Health URL **ServiceDNA** can reach, so it can also probe the service itself |
|
|
48
|
+
| `SERVICEDNA_HEALTH_PATH` | `/health` | Path the heartbeat checks on `http://127.0.0.1:$PORT` |
|
|
49
|
+
| `SERVICEDNA_LOCAL_HEALTH_URL` | from `PORT` + path | Full URL the heartbeat checks, if not on `PORT` |
|
|
50
|
+
| `SERVICEDNA_HEARTBEAT_MS` | `15000` | `0` disables heartbeats |
|
|
51
|
+
| `SERVICEDNA_DEGRADED_MS` | `2000` | Health response slower than this is reported DEGRADED |
|
|
52
|
+
|
|
53
|
+
Standard `OTEL_*` variables still apply and win over these defaults.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "servicedna"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Connect a Python service to ServiceDNA: traces, self-registration and health heartbeats with two environment variables."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"opentelemetry-api~=1.45",
|
|
14
|
+
"opentelemetry-sdk~=1.45",
|
|
15
|
+
"opentelemetry-exporter-otlp-proto-http~=1.45",
|
|
16
|
+
"opentelemetry-instrumentation==0.66b0",
|
|
17
|
+
# Instrumentations only activate when their library is installed, so the common ones ship by default.
|
|
18
|
+
"opentelemetry-instrumentation-asgi==0.66b0",
|
|
19
|
+
"opentelemetry-instrumentation-wsgi==0.66b0",
|
|
20
|
+
"opentelemetry-instrumentation-fastapi==0.66b0",
|
|
21
|
+
"opentelemetry-instrumentation-flask==0.66b0",
|
|
22
|
+
"opentelemetry-instrumentation-django==0.66b0",
|
|
23
|
+
"opentelemetry-instrumentation-requests==0.66b0",
|
|
24
|
+
"opentelemetry-instrumentation-httpx==0.66b0",
|
|
25
|
+
"opentelemetry-instrumentation-urllib3==0.66b0",
|
|
26
|
+
"opentelemetry-instrumentation-aiohttp-client==0.66b0",
|
|
27
|
+
"opentelemetry-instrumentation-grpc==0.66b0",
|
|
28
|
+
"opentelemetry-instrumentation-sqlalchemy==0.66b0",
|
|
29
|
+
"opentelemetry-instrumentation-psycopg2==0.66b0",
|
|
30
|
+
"opentelemetry-instrumentation-redis==0.66b0",
|
|
31
|
+
"opentelemetry-instrumentation-kafka-python==0.66b0",
|
|
32
|
+
"opentelemetry-instrumentation-confluent-kafka==0.66b0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
test = ["pytest", "pytest-asyncio", "fastapi", "httpx", "uvicorn", "grpcio", "grpcio-health-checking"]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Source = "https://github.com/varuns2903/servicedna/tree/main/sdks/python"
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
servicedna-run = "servicedna.run:main"
|
|
43
|
+
|
|
44
|
+
[project.entry-points.opentelemetry_distro]
|
|
45
|
+
servicedna = "servicedna.distro:ServiceDnaDistro"
|
|
46
|
+
|
|
47
|
+
[project.entry-points.opentelemetry_configurator]
|
|
48
|
+
servicedna = "servicedna.distro:ServiceDnaConfigurator"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/servicedna"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""ServiceDNA SDK for Python.
|
|
2
|
+
|
|
3
|
+
Run a program with telemetry (recommended — instruments libraries before they're imported):
|
|
4
|
+
|
|
5
|
+
servicedna-run uvicorn app.main:app
|
|
6
|
+
|
|
7
|
+
Or start it from code, first thing in your entry point:
|
|
8
|
+
|
|
9
|
+
import servicedna
|
|
10
|
+
servicedna.start()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from . import config as _config
|
|
19
|
+
from .bodies import capture, tag
|
|
20
|
+
|
|
21
|
+
__all__ = ["start", "capture", "tag"]
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger("servicedna")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def start() -> bool:
|
|
28
|
+
"""Configures tracing, instruments installed libraries, and starts heartbeats. Returns False
|
|
29
|
+
(and does nothing) when SERVICEDNA_URL and SERVICEDNA_KEY aren't set."""
|
|
30
|
+
cfg = _config.resolve()
|
|
31
|
+
if not cfg.enabled:
|
|
32
|
+
log.warning("[servicedna] SERVICEDNA_URL and SERVICEDNA_KEY are not set; not sending telemetry")
|
|
33
|
+
return False
|
|
34
|
+
for key, value in _config.otel_environment(cfg).items():
|
|
35
|
+
os.environ.setdefault(key, value)
|
|
36
|
+
|
|
37
|
+
from opentelemetry.instrumentation.auto_instrumentation import initialize
|
|
38
|
+
|
|
39
|
+
initialize() # loads the servicedna distro (which starts the heartbeat) and configurator
|
|
40
|
+
return True
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Request/response body capture for ServiceDNA. Requests carrying the baggage entry
|
|
2
|
+
``sdna.capture=1`` (test runs) are always captured. With ``SERVICEDNA_CAPTURE_ON_ERROR=true``,
|
|
3
|
+
every other request's bodies are held until its response and recorded only if it failed (5xx) —
|
|
4
|
+
successful traffic ships no payloads. Bodies are masked and truncated before being recorded on the
|
|
5
|
+
request's server span."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from opentelemetry import baggage, trace
|
|
14
|
+
|
|
15
|
+
MAX_BYTES = int(os.getenv("SERVICEDNA_CAPTURE_MAX_BYTES", "16384"))
|
|
16
|
+
_SENSITIVE = re.compile(r"pass(word|wd)?|secret|token|api[-_.]?key|authorization|cookie|session|card|cvv|ssn", re.I)
|
|
17
|
+
_SCOPE_KEY = "servicedna.capture"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def capture_on_error() -> bool:
|
|
21
|
+
return os.getenv("SERVICEDNA_CAPTURE_ON_ERROR", "").lower() in ("1", "true", "yes")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def capture_requested() -> bool:
|
|
25
|
+
return baggage.get_baggage("sdna.capture") == "1"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def redact(text: str) -> str:
|
|
29
|
+
"""Masks credential-like fields in JSON; other text is kept. Always truncates."""
|
|
30
|
+
try:
|
|
31
|
+
text = json.dumps(_mask(json.loads(text)), separators=(",", ":"))
|
|
32
|
+
except (ValueError, TypeError):
|
|
33
|
+
pass
|
|
34
|
+
return text if len(text) <= MAX_BYTES else text[:MAX_BYTES] + "…[truncated]"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _mask(value):
|
|
38
|
+
if isinstance(value, dict):
|
|
39
|
+
return {k: "[masked]" if _SENSITIVE.search(str(k)) else _mask(v) for k, v in value.items()}
|
|
40
|
+
if isinstance(value, list):
|
|
41
|
+
return [_mask(v) for v in value]
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def capture(name: str, value) -> None:
|
|
46
|
+
"""Records a value computed inside the service on the current span, for test runs:
|
|
47
|
+
``servicedna.capture("order.total", total)``. Does nothing outside a capture run."""
|
|
48
|
+
if not capture_requested():
|
|
49
|
+
return
|
|
50
|
+
span = trace.get_current_span()
|
|
51
|
+
if span.is_recording():
|
|
52
|
+
text = value if isinstance(value, str) else json.dumps(value, default=str)
|
|
53
|
+
span.set_attribute(f"sdna.capture.{name}", redact(text))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def tag(name: str, value) -> None:
|
|
57
|
+
"""Tags the current span with a business key, so ServiceDNA can follow it across services,
|
|
58
|
+
traces and logs — even where trace context was lost: ``servicedna.tag("orderId", order_id)``.
|
|
59
|
+
Unlike capture(), it applies to all traffic; use ids, not personal data."""
|
|
60
|
+
if value is None:
|
|
61
|
+
return
|
|
62
|
+
span = trace.get_current_span()
|
|
63
|
+
if span.is_recording():
|
|
64
|
+
span.set_attribute(f"sdna.key.{name}", str(value))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ASGI instrumentation hooks (FastAPI, Starlette). The receive/send hooks run on the
|
|
68
|
+
# instrumentation's own receive/send spans, so the server span is remembered on the ASGI scope.
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def server_request_hook(span, scope):
|
|
72
|
+
if span is None or not span.is_recording():
|
|
73
|
+
return
|
|
74
|
+
if capture_requested():
|
|
75
|
+
scope[_SCOPE_KEY] = {"span": span, "on_error": False, "status": None, "request": bytearray(), "response": bytearray()}
|
|
76
|
+
elif capture_on_error():
|
|
77
|
+
scope[_SCOPE_KEY] = {"span": span, "on_error": True, "status": None, "request": bytearray(), "response": bytearray()}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def client_request_hook(span, scope, message):
|
|
81
|
+
state = scope.get(_SCOPE_KEY)
|
|
82
|
+
if state is not None and message.get("type") == "http.request":
|
|
83
|
+
_append(state["request"], message.get("body", b""))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def client_response_hook(span, scope, message):
|
|
87
|
+
state = scope.get(_SCOPE_KEY)
|
|
88
|
+
if state is None:
|
|
89
|
+
return
|
|
90
|
+
if message.get("type") == "http.response.start":
|
|
91
|
+
state["status"] = message.get("status")
|
|
92
|
+
return
|
|
93
|
+
if message.get("type") != "http.response.body":
|
|
94
|
+
return
|
|
95
|
+
_append(state["response"], message.get("body", b""))
|
|
96
|
+
if not message.get("more_body", False):
|
|
97
|
+
scope.pop(_SCOPE_KEY, None)
|
|
98
|
+
server_span = state["span"]
|
|
99
|
+
if state["on_error"]:
|
|
100
|
+
if (state["status"] or 0) < 500:
|
|
101
|
+
return
|
|
102
|
+
server_span.set_attribute("sdna.captured_on_error", True)
|
|
103
|
+
else:
|
|
104
|
+
server_span.set_attribute("sdna.captured", True)
|
|
105
|
+
if state["request"]:
|
|
106
|
+
server_span.set_attribute("sdna.request.body", redact(state["request"].decode("utf-8", "replace")))
|
|
107
|
+
if state["response"]:
|
|
108
|
+
server_span.set_attribute("sdna.response.body", redact(state["response"].decode("utf-8", "replace")))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _append(buffer: bytearray, chunk: bytes) -> None:
|
|
112
|
+
if chunk and len(buffer) <= MAX_BYTES:
|
|
113
|
+
buffer.extend(chunk)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def instrument_fastapi() -> bool:
|
|
117
|
+
"""Instruments FastAPI with body capture. Returns False if FastAPI instrumentation isn't installed."""
|
|
118
|
+
try:
|
|
119
|
+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|
120
|
+
except ImportError:
|
|
121
|
+
return False
|
|
122
|
+
FastAPIInstrumentor().instrument(
|
|
123
|
+
server_request_hook=server_request_hook,
|
|
124
|
+
client_request_hook=client_request_hook,
|
|
125
|
+
client_response_hook=client_response_hook,
|
|
126
|
+
)
|
|
127
|
+
return True
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Resolves SDK settings from environment variables. Only SERVICEDNA_URL and SERVICEDNA_KEY are
|
|
2
|
+
required; everything else has a default."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Mapping, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Config:
|
|
13
|
+
url: str
|
|
14
|
+
key: str
|
|
15
|
+
service_name: str
|
|
16
|
+
environment: Optional[str]
|
|
17
|
+
version: Optional[str]
|
|
18
|
+
health_url: Optional[str] # what ServiceDNA probes; only reported when set
|
|
19
|
+
local_health_url: Optional[str] # what the heartbeat checks, over loopback
|
|
20
|
+
heartbeat_seconds: float
|
|
21
|
+
degraded_ms: int
|
|
22
|
+
logs: bool = True # SERVICEDNA_LOGS=false stops sending the logging module's records
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def enabled(self) -> bool:
|
|
26
|
+
return bool(self.url and self.key)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve(env: Mapping[str, str] = os.environ) -> Config:
|
|
30
|
+
port = env.get("PORT")
|
|
31
|
+
health_path = env.get("SERVICEDNA_HEALTH_PATH", "/health")
|
|
32
|
+
return Config(
|
|
33
|
+
url=env.get("SERVICEDNA_URL", "").rstrip("/"),
|
|
34
|
+
key=env.get("SERVICEDNA_KEY", ""),
|
|
35
|
+
service_name=env.get("OTEL_SERVICE_NAME") or env.get("SERVICEDNA_SERVICE") or _default_service_name(),
|
|
36
|
+
environment=env.get("SERVICEDNA_ENV") or None,
|
|
37
|
+
version=env.get("SERVICEDNA_VERSION") or None,
|
|
38
|
+
health_url=env.get("SERVICEDNA_HEALTH_URL") or None,
|
|
39
|
+
local_health_url=env.get("SERVICEDNA_LOCAL_HEALTH_URL")
|
|
40
|
+
or (f"http://127.0.0.1:{port}{health_path}" if port else None),
|
|
41
|
+
heartbeat_seconds=int(env.get("SERVICEDNA_HEARTBEAT_MS", "15000")) / 1000,
|
|
42
|
+
degraded_ms=int(env.get("SERVICEDNA_DEGRADED_MS", "2000")),
|
|
43
|
+
logs=env.get("SERVICEDNA_LOGS", "true").lower() not in ("0", "false", "no", "off"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _default_service_name() -> str:
|
|
48
|
+
return os.path.basename(os.getcwd()) or "unknown_service:python"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def otel_environment(config: Config) -> dict:
|
|
52
|
+
"""The OTEL_* settings that send this service's traces and logs to ServiceDNA. Values the user already
|
|
53
|
+
set are respected by the caller (setdefault), so standard OpenTelemetry configuration still
|
|
54
|
+
works."""
|
|
55
|
+
attributes = [f"telemetry.sdk.language=python"]
|
|
56
|
+
if config.environment:
|
|
57
|
+
attributes.append(f"deployment.environment.name={config.environment}")
|
|
58
|
+
if config.version:
|
|
59
|
+
attributes.append(f"service.version={config.version}")
|
|
60
|
+
if config.health_url:
|
|
61
|
+
attributes.append(f"servicedna.health.url={config.health_url}")
|
|
62
|
+
excluded = [config.local_health_url.split("://", 1)[1].split("/", 1)[1]] if config.local_health_url else []
|
|
63
|
+
logs = (
|
|
64
|
+
{
|
|
65
|
+
# Records from the logging module, with the active trace and span ids.
|
|
66
|
+
"OTEL_LOGS_EXPORTER": "otlp",
|
|
67
|
+
"OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED": "true",
|
|
68
|
+
"OTEL_EXPORTER_OTLP_LOGS_PROTOCOL": "http/protobuf",
|
|
69
|
+
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": f"{config.url}/api/v1/otlp/v1/logs",
|
|
70
|
+
"OTEL_EXPORTER_OTLP_LOGS_HEADERS": f"x-servicedna-key={config.key}",
|
|
71
|
+
}
|
|
72
|
+
if config.logs
|
|
73
|
+
else {"OTEL_LOGS_EXPORTER": "none"}
|
|
74
|
+
)
|
|
75
|
+
return {
|
|
76
|
+
"OTEL_SERVICE_NAME": config.service_name,
|
|
77
|
+
"OTEL_RESOURCE_ATTRIBUTES": ",".join(attributes),
|
|
78
|
+
"OTEL_TRACES_EXPORTER": "otlp",
|
|
79
|
+
"OTEL_METRICS_EXPORTER": "none",
|
|
80
|
+
**logs,
|
|
81
|
+
"OTEL_EXPORTER_OTLP_TRACES_PROTOCOL": "http/protobuf",
|
|
82
|
+
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": f"{config.url}/api/v1/otlp/v1/traces",
|
|
83
|
+
"OTEL_EXPORTER_OTLP_TRACES_HEADERS": f"x-servicedna-key={config.key}",
|
|
84
|
+
# Health probes (ServiceDNA's and the heartbeat's) aren't traffic worth tracing.
|
|
85
|
+
"OTEL_PYTHON_EXCLUDED_URLS": ",".join(f"/{path}" for path in excluded),
|
|
86
|
+
"OTEL_PYTHON_DISTRO": "servicedna",
|
|
87
|
+
"OTEL_PYTHON_CONFIGURATOR": "servicedna",
|
|
88
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""OpenTelemetry distro and configurator entry points, selected by OTEL_PYTHON_DISTRO /
|
|
2
|
+
OTEL_PYTHON_CONFIGURATOR=servicedna (set by servicedna-run and servicedna.start)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from opentelemetry.instrumentation.distro import BaseDistro
|
|
10
|
+
from opentelemetry.sdk._configuration import _OTelSDKConfigurator
|
|
11
|
+
|
|
12
|
+
from . import bodies as _bodies
|
|
13
|
+
from . import grpc_capture as _grpc_capture
|
|
14
|
+
from . import config as _config
|
|
15
|
+
from . import heartbeat
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ServiceDnaDistro(BaseDistro):
|
|
19
|
+
def _configure(self, **kwargs):
|
|
20
|
+
cfg = _config.resolve()
|
|
21
|
+
if not cfg.enabled:
|
|
22
|
+
return
|
|
23
|
+
for key, value in _config.otel_environment(cfg).items():
|
|
24
|
+
os.environ.setdefault(key, value)
|
|
25
|
+
# FastAPI is instrumented here, with body-capture hooks, instead of by auto-instrumentation.
|
|
26
|
+
if _bodies.instrument_fastapi():
|
|
27
|
+
disabled = [d for d in os.environ.get("OTEL_PYTHON_DISABLED_INSTRUMENTATIONS", "").split(",") if d]
|
|
28
|
+
os.environ["OTEL_PYTHON_DISABLED_INSTRUMENTATIONS"] = ",".join(disabled + ["fastapi"])
|
|
29
|
+
# gRPC request/response messages, for test runs (and failures with capture on error).
|
|
30
|
+
_grpc_capture.install()
|
|
31
|
+
if cfg.logs:
|
|
32
|
+
_bridge_uvicorn_logs()
|
|
33
|
+
heartbeat.start(cfg)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _bridge_uvicorn_logs() -> None:
|
|
37
|
+
"""uvicorn's loggers don't propagate to the root logger, where OpenTelemetry's handler sits.
|
|
38
|
+
Once uvicorn has configured logging, its "uvicorn" logger (startup, errors and tracebacks —
|
|
39
|
+
not per-request access lines, which spans already cover) gets that handler too."""
|
|
40
|
+
try:
|
|
41
|
+
import uvicorn.config
|
|
42
|
+
except ImportError:
|
|
43
|
+
return
|
|
44
|
+
original = uvicorn.config.Config.configure_logging
|
|
45
|
+
if getattr(original, "_servicedna", False):
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
def configure_logging(self):
|
|
49
|
+
original(self)
|
|
50
|
+
handlers = [h for h in logging.getLogger().handlers if type(h).__name__ == "LoggingHandler"]
|
|
51
|
+
target = logging.getLogger("uvicorn")
|
|
52
|
+
for handler in handlers:
|
|
53
|
+
if handler not in target.handlers:
|
|
54
|
+
target.addHandler(handler)
|
|
55
|
+
|
|
56
|
+
configure_logging._servicedna = True
|
|
57
|
+
uvicorn.config.Config.configure_logging = configure_logging
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ServiceDnaConfigurator(_OTelSDKConfigurator):
|
|
61
|
+
"""The standard OpenTelemetry SDK setup (tracer provider, batch span processor, exporter)."""
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""gRPC message capture: the request and response messages of unary calls a service handles,
|
|
2
|
+
recorded on its server span as JSON (masked and truncated like HTTP bodies) — for ServiceDNA test
|
|
3
|
+
runs (baggage ``sdna.capture=1``), and for failed calls with ``SERVICEDNA_CAPTURE_ON_ERROR=true``.
|
|
4
|
+
|
|
5
|
+
Installed by the distro: servers made with ``grpc.server`` / ``grpc.aio.server`` get the
|
|
6
|
+
interceptor after OpenTelemetry's, so the server span and the caller's baggage are current."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
|
|
12
|
+
from opentelemetry import trace
|
|
13
|
+
|
|
14
|
+
from .bodies import capture_on_error, capture_requested, redact
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _json(message) -> str:
|
|
18
|
+
try:
|
|
19
|
+
from google.protobuf.json_format import MessageToJson
|
|
20
|
+
|
|
21
|
+
return MessageToJson(message, preserving_proto_field_name=True, indent=None)
|
|
22
|
+
except Exception: # not a protobuf message
|
|
23
|
+
return str(message)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _record(span, request, response, requested: bool) -> None:
|
|
27
|
+
if not span.is_recording():
|
|
28
|
+
return
|
|
29
|
+
span.set_attribute("sdna.request.body", redact(_json(request)))
|
|
30
|
+
if response is not None:
|
|
31
|
+
span.set_attribute("sdna.response.body", redact(_json(response)))
|
|
32
|
+
span.set_attribute("sdna.captured" if requested else "sdna.captured_on_error", True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _wrap_handler(handler, aio: bool):
|
|
36
|
+
import grpc
|
|
37
|
+
|
|
38
|
+
if handler is None or handler.unary_unary is None:
|
|
39
|
+
return handler # streaming calls aren't captured
|
|
40
|
+
behavior = handler.unary_unary
|
|
41
|
+
|
|
42
|
+
if aio:
|
|
43
|
+
|
|
44
|
+
async def unary_unary(request, context):
|
|
45
|
+
requested = capture_requested()
|
|
46
|
+
if not requested and not capture_on_error():
|
|
47
|
+
return await behavior(request, context)
|
|
48
|
+
span = trace.get_current_span()
|
|
49
|
+
try:
|
|
50
|
+
response = await behavior(request, context)
|
|
51
|
+
except BaseException:
|
|
52
|
+
_record(span, request, None, requested)
|
|
53
|
+
raise
|
|
54
|
+
if requested:
|
|
55
|
+
_record(span, request, response, True)
|
|
56
|
+
return response
|
|
57
|
+
|
|
58
|
+
else:
|
|
59
|
+
|
|
60
|
+
def unary_unary(request, context):
|
|
61
|
+
requested = capture_requested()
|
|
62
|
+
if not requested and not capture_on_error():
|
|
63
|
+
return behavior(request, context)
|
|
64
|
+
span = trace.get_current_span()
|
|
65
|
+
try:
|
|
66
|
+
response = behavior(request, context)
|
|
67
|
+
except BaseException:
|
|
68
|
+
_record(span, request, None, requested)
|
|
69
|
+
raise
|
|
70
|
+
if requested:
|
|
71
|
+
_record(span, request, response, True)
|
|
72
|
+
return response
|
|
73
|
+
|
|
74
|
+
return grpc.unary_unary_rpc_method_handler(
|
|
75
|
+
unary_unary, request_deserializer=handler.request_deserializer, response_serializer=handler.response_serializer
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def server_interceptor():
|
|
80
|
+
import grpc
|
|
81
|
+
|
|
82
|
+
class CaptureInterceptor(grpc.ServerInterceptor):
|
|
83
|
+
def intercept_service(self, continuation, handler_call_details):
|
|
84
|
+
return _wrap_handler(continuation(handler_call_details), aio=False)
|
|
85
|
+
|
|
86
|
+
return CaptureInterceptor()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def aio_server_interceptor():
|
|
90
|
+
import grpc.aio
|
|
91
|
+
|
|
92
|
+
class AioCaptureInterceptor(grpc.aio.ServerInterceptor):
|
|
93
|
+
async def intercept_service(self, continuation, handler_call_details):
|
|
94
|
+
return _wrap_handler(await continuation(handler_call_details), aio=True)
|
|
95
|
+
|
|
96
|
+
return AioCaptureInterceptor()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def install() -> bool:
|
|
100
|
+
"""Adds the capture interceptor to every gRPC server made from now on. False without grpcio."""
|
|
101
|
+
try:
|
|
102
|
+
import grpc
|
|
103
|
+
import grpc.aio
|
|
104
|
+
except ImportError:
|
|
105
|
+
return False
|
|
106
|
+
if getattr(grpc.server, "_servicedna", False):
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
def patch(module, name, make):
|
|
110
|
+
original = getattr(module, name)
|
|
111
|
+
|
|
112
|
+
@functools.wraps(original)
|
|
113
|
+
def server(*args, **kwargs):
|
|
114
|
+
interceptors = list(kwargs.pop("interceptors", None) or [])
|
|
115
|
+
# grpc.server(thread_pool, handlers, interceptors, ...) also takes them positionally.
|
|
116
|
+
if module is grpc and len(args) >= 3:
|
|
117
|
+
interceptors = list(args[2] or []) + interceptors
|
|
118
|
+
args = args[:2] + args[3:]
|
|
119
|
+
interceptors.append(make())
|
|
120
|
+
return original(*args, interceptors=interceptors, **kwargs)
|
|
121
|
+
|
|
122
|
+
server._servicedna = True
|
|
123
|
+
setattr(module, name, server)
|
|
124
|
+
|
|
125
|
+
patch(grpc, "server", server_interceptor)
|
|
126
|
+
patch(grpc.aio, "server", aio_server_interceptor)
|
|
127
|
+
return True
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Checks the service's own health endpoint on an interval and reports the result to ServiceDNA,
|
|
2
|
+
which registers the service on first contact. Its HTTP calls are made with tracing suppressed so
|
|
3
|
+
heartbeats don't show up as the service's traffic."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
from opentelemetry import context
|
|
15
|
+
|
|
16
|
+
from .config import Config
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("servicedna")
|
|
19
|
+
_started = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def start(config: Config) -> None:
|
|
23
|
+
global _started
|
|
24
|
+
if _started or not config.local_health_url or config.heartbeat_seconds <= 0:
|
|
25
|
+
return
|
|
26
|
+
_started = True
|
|
27
|
+
thread = threading.Thread(target=_loop, args=(config,), name="servicedna-heartbeat", daemon=True)
|
|
28
|
+
thread.start()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _loop(config: Config) -> None:
|
|
32
|
+
while True:
|
|
33
|
+
time.sleep(config.heartbeat_seconds)
|
|
34
|
+
token = context.attach(context.set_value(context._SUPPRESS_INSTRUMENTATION_KEY, True))
|
|
35
|
+
try:
|
|
36
|
+
_beat(config)
|
|
37
|
+
except Exception as e: # never let the heartbeat thread die
|
|
38
|
+
log.warning("[servicedna] heartbeat failed: %s", e)
|
|
39
|
+
finally:
|
|
40
|
+
context.detach(token)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _beat(config: Config) -> None:
|
|
44
|
+
start = time.monotonic()
|
|
45
|
+
status, message = "DOWN", "health check failed"
|
|
46
|
+
try:
|
|
47
|
+
with urllib.request.urlopen(config.local_health_url, timeout=5) as res:
|
|
48
|
+
body = res.read()
|
|
49
|
+
elapsed_ms = (time.monotonic() - start) * 1000
|
|
50
|
+
status = "DEGRADED" if elapsed_ms > config.degraded_ms else "HEALTHY"
|
|
51
|
+
message = _message(body) or f"HTTP {res.status}"
|
|
52
|
+
except urllib.error.HTTPError as e:
|
|
53
|
+
message = _message(e.read()) or f"HTTP {e.code}"
|
|
54
|
+
except Exception as e:
|
|
55
|
+
message = str(e) or type(e).__name__
|
|
56
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
57
|
+
|
|
58
|
+
payload = json.dumps(
|
|
59
|
+
{
|
|
60
|
+
"status": status,
|
|
61
|
+
"latencyMs": latency_ms,
|
|
62
|
+
"message": message[:500],
|
|
63
|
+
"service": config.service_name,
|
|
64
|
+
"environment": config.environment,
|
|
65
|
+
}
|
|
66
|
+
).encode()
|
|
67
|
+
request = urllib.request.Request(
|
|
68
|
+
f"{config.url}/api/v1/ping",
|
|
69
|
+
data=payload,
|
|
70
|
+
headers={"Content-Type": "application/json", "X-API-Key": config.key},
|
|
71
|
+
method="POST",
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
urllib.request.urlopen(request, timeout=5).close()
|
|
75
|
+
except urllib.error.HTTPError as e:
|
|
76
|
+
log.warning("[servicedna] heartbeat rejected: HTTP %s", e.code)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _message(body: bytes):
|
|
80
|
+
try:
|
|
81
|
+
data = json.loads(body)
|
|
82
|
+
return str(data.get("message") or data.get("status") or "") or None
|
|
83
|
+
except (ValueError, AttributeError):
|
|
84
|
+
return None
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""servicedna-run: run a Python program with ServiceDNA telemetry, e.g.
|
|
2
|
+
|
|
3
|
+
SERVICEDNA_URL=... SERVICEDNA_KEY=sdna_ik_... servicedna-run uvicorn app.main:app
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import config as _config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
cfg = _config.resolve()
|
|
16
|
+
if not cfg.enabled:
|
|
17
|
+
print("[servicedna] SERVICEDNA_URL and SERVICEDNA_KEY are not set; not sending telemetry", file=sys.stderr)
|
|
18
|
+
else:
|
|
19
|
+
for key, value in _config.otel_environment(cfg).items():
|
|
20
|
+
os.environ.setdefault(key, value)
|
|
21
|
+
print(
|
|
22
|
+
f"[servicedna] sending {cfg.service_name}"
|
|
23
|
+
+ (f" ({cfg.environment})" if cfg.environment else "")
|
|
24
|
+
+ f" telemetry to {cfg.url}",
|
|
25
|
+
file=sys.stderr,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Same mechanism as opentelemetry-instrument: re-executes the command with auto-instrumentation
|
|
29
|
+
# loaded via sitecustomize, which picks up the servicedna distro and configurator.
|
|
30
|
+
from opentelemetry.instrumentation.auto_instrumentation import run
|
|
31
|
+
|
|
32
|
+
run()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
5
|
+
from fastapi.testclient import TestClient
|
|
6
|
+
from opentelemetry import propagate, trace
|
|
7
|
+
from opentelemetry.baggage.propagation import W3CBaggagePropagator
|
|
8
|
+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|
9
|
+
from opentelemetry.propagators.composite import CompositePropagator
|
|
10
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
11
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
12
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
13
|
+
from opentelemetry.trace import SpanKind
|
|
14
|
+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
15
|
+
|
|
16
|
+
from servicedna import bodies as sdna
|
|
17
|
+
|
|
18
|
+
exporter = InMemorySpanExporter()
|
|
19
|
+
provider = TracerProvider()
|
|
20
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
21
|
+
trace.set_tracer_provider(provider)
|
|
22
|
+
propagate.set_global_textmap(CompositePropagator([TraceContextTextMapPropagator(), W3CBaggagePropagator()]))
|
|
23
|
+
|
|
24
|
+
app = FastAPI()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.post("/charge")
|
|
28
|
+
async def charge(request: Request):
|
|
29
|
+
body = await request.json()
|
|
30
|
+
sdna.capture("computed.total", {"amount": body["amount"] * 2})
|
|
31
|
+
if body.get("fail"):
|
|
32
|
+
raise HTTPException(status_code=503, detail="processor unavailable")
|
|
33
|
+
if body.get("crash"):
|
|
34
|
+
raise RuntimeError("bug")
|
|
35
|
+
if body.get("reject"):
|
|
36
|
+
raise HTTPException(status_code=402, detail="declined")
|
|
37
|
+
return {"status": "CAPTURED", "amount": body["amount"], "apiKey": "secret"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
FastAPIInstrumentor.instrument_app(
|
|
41
|
+
app,
|
|
42
|
+
server_request_hook=sdna.server_request_hook,
|
|
43
|
+
client_request_hook=sdna.client_request_hook,
|
|
44
|
+
client_response_hook=sdna.client_response_hook,
|
|
45
|
+
)
|
|
46
|
+
client = TestClient(app)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def server_span():
|
|
50
|
+
return next(s for s in exporter.get_finished_spans() if s.kind == SpanKind.SERVER)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture(autouse=True)
|
|
54
|
+
def reset():
|
|
55
|
+
exporter.clear()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_records_bodies_of_a_capture_run_masking_credentials():
|
|
59
|
+
client.post(
|
|
60
|
+
"/charge",
|
|
61
|
+
json={"amount": 59, "password": "hunter2"},
|
|
62
|
+
headers={
|
|
63
|
+
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
|
|
64
|
+
"baggage": "sdna.run=run_1,sdna.capture=1",
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
attrs = server_span().attributes
|
|
68
|
+
assert json.loads(attrs["sdna.request.body"]) == {"amount": 59, "password": "[masked]"}
|
|
69
|
+
assert json.loads(attrs["sdna.response.body"]) == {"status": "CAPTURED", "amount": 59, "apiKey": "[masked]"}
|
|
70
|
+
assert attrs["sdna.captured"] is True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_capture_records_computed_values_on_the_current_span():
|
|
74
|
+
client.post("/charge", json={"amount": 5}, headers={"baggage": "sdna.capture=1"})
|
|
75
|
+
captured = [s.attributes.get("sdna.capture.computed.total") for s in exporter.get_finished_spans()]
|
|
76
|
+
assert '{"amount":10}' in [c for c in captured if c]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_leaves_ordinary_requests_alone():
|
|
80
|
+
client.post("/charge", json={"amount": 1})
|
|
81
|
+
attrs = server_span().attributes
|
|
82
|
+
assert "sdna.request.body" not in attrs
|
|
83
|
+
assert "sdna.response.body" not in attrs
|
|
84
|
+
assert all("sdna.capture.computed.total" not in s.attributes for s in exporter.get_finished_spans())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_with_capture_on_error_only_failed_requests_carry_their_bodies(monkeypatch):
|
|
88
|
+
monkeypatch.setenv("SERVICEDNA_CAPTURE_ON_ERROR", "true")
|
|
89
|
+
|
|
90
|
+
client.post("/charge", json={"amount": 1})
|
|
91
|
+
client.post("/charge", json={"amount": 2, "reject": True})
|
|
92
|
+
assert all("sdna.request.body" not in s.attributes for s in exporter.get_finished_spans())
|
|
93
|
+
|
|
94
|
+
exporter.clear()
|
|
95
|
+
client.post("/charge", json={"amount": 3, "fail": True, "password": "x"})
|
|
96
|
+
attrs = server_span().attributes
|
|
97
|
+
assert json.loads(attrs["sdna.request.body"]) == {"amount": 3, "fail": True, "password": "[masked]"}
|
|
98
|
+
assert json.loads(attrs["sdna.response.body"]) == {"detail": "processor unavailable"}
|
|
99
|
+
assert attrs["sdna.captured_on_error"] is True
|
|
100
|
+
assert "sdna.captured" not in attrs
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_capture_on_error_includes_unhandled_exceptions(monkeypatch):
|
|
104
|
+
monkeypatch.setenv("SERVICEDNA_CAPTURE_ON_ERROR", "true")
|
|
105
|
+
TestClient(app, raise_server_exceptions=False).post("/charge", json={"amount": 4, "crash": True})
|
|
106
|
+
attrs = server_span().attributes
|
|
107
|
+
assert json.loads(attrs["sdna.request.body"]) == {"amount": 4, "crash": True}
|
|
108
|
+
assert attrs["sdna.captured_on_error"] is True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_tag_records_a_business_key_outside_capture_runs_too():
|
|
112
|
+
tracer = trace.get_tracer("t")
|
|
113
|
+
with tracer.start_as_current_span("job"):
|
|
114
|
+
sdna.tag("orderId", 17)
|
|
115
|
+
sdna.tag("skipped", None)
|
|
116
|
+
span = next(s for s in exporter.get_finished_spans() if s.name == "job")
|
|
117
|
+
assert span.attributes["sdna.key.orderId"] == "17"
|
|
118
|
+
assert "sdna.key.skipped" not in span.attributes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from servicedna import config
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_disabled_without_url_and_key():
|
|
5
|
+
assert not config.resolve({}).enabled
|
|
6
|
+
assert not config.resolve({"SERVICEDNA_URL": "http://x"}).enabled
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_two_variables_are_enough():
|
|
10
|
+
cfg = config.resolve({"SERVICEDNA_URL": "http://sdna:8080/", "SERVICEDNA_KEY": "sdna_ik_x", "OTEL_SERVICE_NAME": "payments"})
|
|
11
|
+
assert cfg.enabled
|
|
12
|
+
assert cfg.url == "http://sdna:8080"
|
|
13
|
+
assert cfg.service_name == "payments"
|
|
14
|
+
assert cfg.health_url is None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_heartbeat_checks_health_on_port():
|
|
18
|
+
assert config.resolve({"PORT": "4005"}).local_health_url == "http://127.0.0.1:4005/health"
|
|
19
|
+
assert config.resolve({"PORT": "8000", "SERVICEDNA_HEALTH_PATH": "/healthz"}).local_health_url == "http://127.0.0.1:8000/healthz"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_otel_environment_points_exporter_at_servicedna():
|
|
23
|
+
cfg = config.resolve(
|
|
24
|
+
{
|
|
25
|
+
"SERVICEDNA_URL": "http://sdna:8080",
|
|
26
|
+
"SERVICEDNA_KEY": "sdna_ik_x",
|
|
27
|
+
"OTEL_SERVICE_NAME": "payments",
|
|
28
|
+
"SERVICEDNA_ENV": "prod",
|
|
29
|
+
"SERVICEDNA_HEALTH_URL": "http://payments:4005/health",
|
|
30
|
+
"PORT": "4005",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
env = config.otel_environment(cfg)
|
|
34
|
+
assert env["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] == "http://sdna:8080/api/v1/otlp/v1/traces"
|
|
35
|
+
assert env["OTEL_EXPORTER_OTLP_TRACES_HEADERS"] == "x-servicedna-key=sdna_ik_x"
|
|
36
|
+
assert env["OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"] == "http/protobuf"
|
|
37
|
+
assert "deployment.environment.name=prod" in env["OTEL_RESOURCE_ATTRIBUTES"]
|
|
38
|
+
assert "servicedna.health.url=http://payments:4005/health" in env["OTEL_RESOURCE_ATTRIBUTES"]
|
|
39
|
+
assert "telemetry.sdk.language=python" in env["OTEL_RESOURCE_ATTRIBUTES"]
|
|
40
|
+
assert env["OTEL_PYTHON_EXCLUDED_URLS"] == "/health"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_logs_go_to_servicedna_unless_turned_off():
|
|
44
|
+
base = {"SERVICEDNA_URL": "http://sdna:8080", "SERVICEDNA_KEY": "sdna_ik_x"}
|
|
45
|
+
env = config.otel_environment(config.resolve(base))
|
|
46
|
+
assert env["OTEL_LOGS_EXPORTER"] == "otlp"
|
|
47
|
+
assert env["OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED"] == "true"
|
|
48
|
+
assert env["OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"] == "http://sdna:8080/api/v1/otlp/v1/logs"
|
|
49
|
+
assert env["OTEL_EXPORTER_OTLP_LOGS_HEADERS"] == "x-servicedna-key=sdna_ik_x"
|
|
50
|
+
off = config.otel_environment(config.resolve({**base, "SERVICEDNA_LOGS": "false"}))
|
|
51
|
+
assert off["OTEL_LOGS_EXPORTER"] == "none"
|
|
52
|
+
assert "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT" not in off
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_uvicorn_logs_reach_the_opentelemetry_handler():
|
|
56
|
+
import logging
|
|
57
|
+
|
|
58
|
+
import pytest
|
|
59
|
+
|
|
60
|
+
uvicorn_config = pytest.importorskip("uvicorn.config")
|
|
61
|
+
from servicedna import distro
|
|
62
|
+
|
|
63
|
+
class LoggingHandler(logging.Handler): # stands in for OpenTelemetry's
|
|
64
|
+
def __init__(self):
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.records = []
|
|
67
|
+
|
|
68
|
+
def emit(self, record):
|
|
69
|
+
self.records.append(record)
|
|
70
|
+
|
|
71
|
+
handler = LoggingHandler()
|
|
72
|
+
logging.getLogger().addHandler(handler)
|
|
73
|
+
original = uvicorn_config.Config.configure_logging
|
|
74
|
+
try:
|
|
75
|
+
distro._bridge_uvicorn_logs()
|
|
76
|
+
uvicorn_config.Config(app="x:y").configure_logging()
|
|
77
|
+
logging.getLogger("uvicorn.error").error("Exception in ASGI application")
|
|
78
|
+
logging.getLogger("uvicorn.access").info("GET /health 200")
|
|
79
|
+
assert [r.getMessage() for r in handler.records] == ["Exception in ASGI application"]
|
|
80
|
+
finally:
|
|
81
|
+
uvicorn_config.Config.configure_logging = original
|
|
82
|
+
logging.getLogger().removeHandler(handler)
|
|
83
|
+
logging.getLogger("uvicorn").removeHandler(handler)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import grpc
|
|
4
|
+
import pytest
|
|
5
|
+
from grpc_health.v1 import health_pb2, health_pb2_grpc
|
|
6
|
+
from opentelemetry import baggage, context, trace
|
|
7
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
8
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
9
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
10
|
+
|
|
11
|
+
from servicedna import grpc_capture
|
|
12
|
+
|
|
13
|
+
exporter = InMemorySpanExporter()
|
|
14
|
+
provider = TracerProvider()
|
|
15
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
16
|
+
tracer = provider.get_tracer("test")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Health(health_pb2_grpc.HealthServicer):
|
|
20
|
+
async def Check(self, request, context):
|
|
21
|
+
if request.service == "broken":
|
|
22
|
+
await context.abort(grpc.StatusCode.UNAVAILABLE, "down")
|
|
23
|
+
return health_pb2.HealthCheckResponse(status=health_pb2.HealthCheckResponse.SERVING)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SpanAndBaggage(grpc.aio.ServerInterceptor):
|
|
27
|
+
"""Stands in for OpenTelemetry's server interceptor: a server span, with the caller's baggage."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, capture):
|
|
30
|
+
self.capture = capture
|
|
31
|
+
|
|
32
|
+
async def intercept_service(self, continuation, details):
|
|
33
|
+
handler = await continuation(details)
|
|
34
|
+
behavior = handler.unary_unary
|
|
35
|
+
|
|
36
|
+
async def unary_unary(request, ctx):
|
|
37
|
+
token = context.attach(baggage.set_baggage("sdna.capture", "1") if self.capture else context.get_current())
|
|
38
|
+
try:
|
|
39
|
+
with tracer.start_as_current_span("grpc.health.v1.Health/Check", kind=trace.SpanKind.SERVER):
|
|
40
|
+
return await behavior(request, ctx)
|
|
41
|
+
finally:
|
|
42
|
+
context.detach(token)
|
|
43
|
+
|
|
44
|
+
return grpc.unary_unary_rpc_method_handler(
|
|
45
|
+
unary_unary, request_deserializer=handler.request_deserializer, response_serializer=handler.response_serializer
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def check(service: str, capture: bool):
|
|
50
|
+
server = grpc.aio.server(interceptors=[SpanAndBaggage(capture), grpc_capture.aio_server_interceptor()])
|
|
51
|
+
health_pb2_grpc.add_HealthServicer_to_server(Health(), server)
|
|
52
|
+
port = server.add_insecure_port("127.0.0.1:0")
|
|
53
|
+
await server.start()
|
|
54
|
+
try:
|
|
55
|
+
async with grpc.aio.insecure_channel(f"127.0.0.1:{port}") as channel:
|
|
56
|
+
try:
|
|
57
|
+
await health_pb2_grpc.HealthStub(channel).Check(health_pb2.HealthCheckRequest(service=service))
|
|
58
|
+
except grpc.aio.AioRpcError:
|
|
59
|
+
pass
|
|
60
|
+
finally:
|
|
61
|
+
await server.stop(None)
|
|
62
|
+
return next(s for s in exporter.get_finished_spans() if s.kind == trace.SpanKind.SERVER).attributes
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.fixture(autouse=True)
|
|
66
|
+
def reset(monkeypatch):
|
|
67
|
+
exporter.clear()
|
|
68
|
+
monkeypatch.delenv("SERVICEDNA_CAPTURE_ON_ERROR", raising=False)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_a_capture_run_records_the_messages():
|
|
73
|
+
attrs = await check("payments", capture=True)
|
|
74
|
+
assert json.loads(attrs["sdna.request.body"]) == {"service": "payments"}
|
|
75
|
+
assert json.loads(attrs["sdna.response.body"]) == {"status": "SERVING"}
|
|
76
|
+
assert attrs["sdna.captured"] is True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.asyncio
|
|
80
|
+
async def test_ordinary_calls_are_left_alone():
|
|
81
|
+
attrs = await check("payments", capture=False)
|
|
82
|
+
assert "sdna.request.body" not in attrs
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_capture_on_error_records_failed_calls_only(monkeypatch):
|
|
87
|
+
monkeypatch.setenv("SERVICEDNA_CAPTURE_ON_ERROR", "true")
|
|
88
|
+
assert "sdna.request.body" not in await check("payments", capture=False)
|
|
89
|
+
exporter.clear()
|
|
90
|
+
attrs = await check("broken", capture=False)
|
|
91
|
+
assert json.loads(attrs["sdna.request.body"]) == {"service": "broken"}
|
|
92
|
+
assert attrs["sdna.captured_on_error"] is True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_install_adds_the_interceptor_to_new_servers():
|
|
96
|
+
import grpc.aio
|
|
97
|
+
|
|
98
|
+
assert grpc_capture.install()
|
|
99
|
+
assert getattr(grpc.aio.server, "_servicedna", False)
|
|
100
|
+
assert grpc_capture.install() # idempotent
|