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.
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
@@ -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