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 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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,8 @@
1
+ [console_scripts]
2
+ servicedna-run = servicedna.run:main
3
+
4
+ [opentelemetry_configurator]
5
+ servicedna = servicedna.distro:ServiceDnaConfigurator
6
+
7
+ [opentelemetry_distro]
8
+ servicedna = servicedna.distro:ServiceDnaDistro