servicedna 0.1.0__py3-none-any.whl
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/__init__.py +40 -0
- servicedna/bodies.py +127 -0
- servicedna/config.py +88 -0
- servicedna/distro.py +61 -0
- servicedna/grpc_capture.py +127 -0
- servicedna/heartbeat.py +84 -0
- servicedna/run.py +32 -0
- servicedna-0.1.0.dist-info/METADATA +89 -0
- servicedna-0.1.0.dist-info/RECORD +11 -0
- servicedna-0.1.0.dist-info/WHEEL +4 -0
- servicedna-0.1.0.dist-info/entry_points.txt +8 -0
servicedna/__init__.py
ADDED
|
@@ -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
|
servicedna/bodies.py
ADDED
|
@@ -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
|
servicedna/config.py
ADDED
|
@@ -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
|
+
}
|
servicedna/distro.py
ADDED
|
@@ -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
|
servicedna/heartbeat.py
ADDED
|
@@ -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
|
servicedna/run.py
ADDED
|
@@ -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,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,11 @@
|
|
|
1
|
+
servicedna/__init__.py,sha256=91OAiFRM2bT8Gi9WGCYO5J1tNw0qFoHo6uv0YHkOFL4,1169
|
|
2
|
+
servicedna/bodies.py,sha256=_F6uNyafaXLwmBX95S0CpXrnAA7MgY5NVfbhPvOdgBI,4892
|
|
3
|
+
servicedna/config.py,sha256=SxlrHX_iKwrER-B8o5vz-STGC0xTG38IwcDegEEtMhY,3803
|
|
4
|
+
servicedna/distro.py,sha256=qvXAU1BYQ4Y5so98G6x5VXCQuA7WKUH6BvmDiIRam_Q,2424
|
|
5
|
+
servicedna/grpc_capture.py,sha256=O_NxmTIHn3My3N9-tIdFkaSfhVZf5lKnDh2QYiE4IhM,4422
|
|
6
|
+
servicedna/heartbeat.py,sha256=n5v5supePbcBBfMcJVogwa2_WpLh3bFcUDoympdWKIc,2726
|
|
7
|
+
servicedna/run.py,sha256=3A3kHgfVowWccs0gkSvNae9lzNb2UaMTtVqBrf9gCvM,1053
|
|
8
|
+
servicedna-0.1.0.dist-info/METADATA,sha256=ZULomMN6UcJcz1uq6WqZGCybCd0LtvDRWSfWd7_wU7Q,3894
|
|
9
|
+
servicedna-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
servicedna-0.1.0.dist-info/entry_points.txt,sha256=4nI7fRCVAiHJlZWfuNXTImOk_zNQyJRvWyBk28HJ2bA,211
|
|
11
|
+
servicedna-0.1.0.dist-info/RECORD,,
|