grpc-server-kit 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.
- grpc_server_kit/__init__.py +34 -0
- grpc_server_kit/__version__.py +1 -0
- grpc_server_kit/aio/__init__.py +44 -0
- grpc_server_kit/aio/app.py +368 -0
- grpc_server_kit/aio/builder.py +63 -0
- grpc_server_kit/aio/dishka/__init__.py +28 -0
- grpc_server_kit/aio/dishka/bundle.py +70 -0
- grpc_server_kit/aio/dishka/interceptors.py +116 -0
- grpc_server_kit/aio/dishka/metrics.py +50 -0
- grpc_server_kit/aio/dishka/observability.py +86 -0
- grpc_server_kit/aio/dishka/server.py +51 -0
- grpc_server_kit/aio/health/__init__.py +36 -0
- grpc_server_kit/aio/health/cache.py +89 -0
- grpc_server_kit/aio/health/checkers/__init__.py +16 -0
- grpc_server_kit/aio/health/checkers/postgres.py +115 -0
- grpc_server_kit/aio/health/checkers/redis.py +88 -0
- grpc_server_kit/aio/health/checkers/utils.py +97 -0
- grpc_server_kit/aio/health/orchestrator.py +122 -0
- grpc_server_kit/aio/health/protocols.py +43 -0
- grpc_server_kit/aio/health/servicer.py +229 -0
- grpc_server_kit/aio/interceptors/__init__.py +62 -0
- grpc_server_kit/aio/interceptors/base.py +235 -0
- grpc_server_kit/aio/interceptors/context.py +179 -0
- grpc_server_kit/aio/interceptors/exception_handler.py +167 -0
- grpc_server_kit/aio/interceptors/metadata.py +57 -0
- grpc_server_kit/aio/interceptors/metrics.py +104 -0
- grpc_server_kit/aio/interceptors/protocols.py +54 -0
- grpc_server_kit/aio/interceptors/request_logger.py +103 -0
- grpc_server_kit/aio/interceptors/sentry.py +102 -0
- grpc_server_kit/aio/interceptors/tracing.py +130 -0
- grpc_server_kit/aio/observability/__init__.py +5 -0
- grpc_server_kit/aio/observability/tracing.py +48 -0
- grpc_server_kit/aio/runtime.py +206 -0
- grpc_server_kit/aio/server.py +191 -0
- grpc_server_kit/config.py +100 -0
- grpc_server_kit/constants.py +34 -0
- grpc_server_kit/credentials.py +112 -0
- grpc_server_kit/dishka/__init__.py +38 -0
- grpc_server_kit/dishka/settings.py +41 -0
- grpc_server_kit/interceptors/__init__.py +15 -0
- grpc_server_kit/interceptors/constants.py +44 -0
- grpc_server_kit/interceptors/utils.py +74 -0
- grpc_server_kit/observability/__init__.py +12 -0
- grpc_server_kit/observability/metrics.py +116 -0
- grpc_server_kit/observability/naming.py +26 -0
- grpc_server_kit/observability/protocols.py +86 -0
- grpc_server_kit/observability/sentry.py +63 -0
- grpc_server_kit/options.py +136 -0
- grpc_server_kit/protocols.py +114 -0
- grpc_server_kit/py.typed +0 -0
- grpc_server_kit/server.py +59 -0
- grpc_server_kit/settings.py +151 -0
- grpc_server_kit/signals.py +228 -0
- grpc_server_kit-0.1.0.dist-info/METADATA +484 -0
- grpc_server_kit-0.1.0.dist-info/RECORD +57 -0
- grpc_server_kit-0.1.0.dist-info/WHEEL +4 -0
- grpc_server_kit-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Dishka provider exposing gRPC server settings + service name to the container.
|
|
2
|
+
|
|
3
|
+
Shared (sync) provider: it only holds an already-built settings object, so it lives at
|
|
4
|
+
the top level rather than under ``aio``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dishka import Provider, Scope, provide
|
|
10
|
+
|
|
11
|
+
from grpc_server_kit.protocols import GrpcServerSettingsProtocol, GrpcServiceName
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GrpcServerSettingsProvider(Provider):
|
|
15
|
+
"""Provide the gRPC server settings (as ``GrpcServerSettingsProtocol``) and service name.
|
|
16
|
+
|
|
17
|
+
Construct with the service's settings object (anything structurally satisfying
|
|
18
|
+
``GrpcServerSettingsProtocol`` — e.g. ``BaseGrpcServerSettings``) and the fully-qualified
|
|
19
|
+
gRPC service name. Replaces the per-service settings-aliasing boilerplate that the other
|
|
20
|
+
kit providers depend on.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
scope = Scope.APP
|
|
24
|
+
|
|
25
|
+
def __init__(self, settings: GrpcServerSettingsProtocol, *, service_name: str) -> None:
|
|
26
|
+
super().__init__()
|
|
27
|
+
self._settings = settings
|
|
28
|
+
self._service_name = service_name
|
|
29
|
+
|
|
30
|
+
@provide
|
|
31
|
+
def settings(self) -> GrpcServerSettingsProtocol:
|
|
32
|
+
"""Provide the gRPC server settings."""
|
|
33
|
+
return self._settings
|
|
34
|
+
|
|
35
|
+
@provide
|
|
36
|
+
def service_name(self) -> GrpcServiceName:
|
|
37
|
+
"""Provide the fully-qualified gRPC service name (typed DI key)."""
|
|
38
|
+
return GrpcServiceName(self._service_name)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = ["GrpcServerSettingsProvider"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Shared, transport-agnostic interceptor helpers.
|
|
2
|
+
|
|
3
|
+
The async server interceptors live under :mod:`grpc_server_kit.aio.interceptors`; this
|
|
4
|
+
package holds only the shared constants and utilities they build on.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .constants import SKIPPED_HEALTH_METHODS, X_REQUEST_ID
|
|
8
|
+
from .utils import is_server_error, resolve_status_code
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"SKIPPED_HEALTH_METHODS",
|
|
12
|
+
"X_REQUEST_ID",
|
|
13
|
+
"is_server_error",
|
|
14
|
+
"resolve_status_code",
|
|
15
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Constants shared across gRPC interceptors."""
|
|
2
|
+
|
|
3
|
+
from grpc_server_kit.constants import HEALTH_SERVICE_NAME
|
|
4
|
+
|
|
5
|
+
# gRPC Health methods to skip in metrics, logging, tracing, and error tracking
|
|
6
|
+
# (derived from the single canonical health service name).
|
|
7
|
+
SKIPPED_HEALTH_METHODS = frozenset(
|
|
8
|
+
{
|
|
9
|
+
f"/{HEALTH_SERVICE_NAME}/Check",
|
|
10
|
+
f"/{HEALTH_SERVICE_NAME}/Watch",
|
|
11
|
+
}
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Common header names
|
|
15
|
+
X_REQUEST_ID = "x-request-id"
|
|
16
|
+
|
|
17
|
+
# Context variable names
|
|
18
|
+
REQUEST_ID_KEY = "request_id"
|
|
19
|
+
METHOD_KEY = "method"
|
|
20
|
+
|
|
21
|
+
# OpenTelemetry semantic-convention attributes
|
|
22
|
+
OTEL_RPC_SYSTEM = "rpc.system"
|
|
23
|
+
OTEL_RPC_METHOD = "rpc.method"
|
|
24
|
+
OTEL_RPC_SERVICE = "rpc.service"
|
|
25
|
+
OTEL_RPC_GRPC_STATUS_CODE = "rpc.grpc.status_code"
|
|
26
|
+
OTEL_REQUEST_ID = "request.id"
|
|
27
|
+
|
|
28
|
+
# Sentry tag names
|
|
29
|
+
SENTRY_GRPC_METHOD = "grpc_method"
|
|
30
|
+
SENTRY_REQUEST_ID = "request_id"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"METHOD_KEY",
|
|
34
|
+
"OTEL_REQUEST_ID",
|
|
35
|
+
"OTEL_RPC_GRPC_STATUS_CODE",
|
|
36
|
+
"OTEL_RPC_METHOD",
|
|
37
|
+
"OTEL_RPC_SERVICE",
|
|
38
|
+
"OTEL_RPC_SYSTEM",
|
|
39
|
+
"REQUEST_ID_KEY",
|
|
40
|
+
"SENTRY_GRPC_METHOD",
|
|
41
|
+
"SENTRY_REQUEST_ID",
|
|
42
|
+
"SKIPPED_HEALTH_METHODS",
|
|
43
|
+
"X_REQUEST_ID",
|
|
44
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Utility functions for gRPC interceptors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import grpc
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_server_error(code: grpc.StatusCode) -> bool:
|
|
11
|
+
"""Check if the gRPC status code is a server-side error (5xx equivalent).
|
|
12
|
+
|
|
13
|
+
Server-side errors include internal failures, timeouts, and unavailability.
|
|
14
|
+
If the code is not one of these, it is either OK or a client-side error (4xx equivalent).
|
|
15
|
+
"""
|
|
16
|
+
return code in (
|
|
17
|
+
grpc.StatusCode.INTERNAL,
|
|
18
|
+
grpc.StatusCode.UNKNOWN,
|
|
19
|
+
grpc.StatusCode.DATA_LOSS,
|
|
20
|
+
grpc.StatusCode.UNIMPLEMENTED,
|
|
21
|
+
grpc.StatusCode.UNAVAILABLE,
|
|
22
|
+
grpc.StatusCode.DEADLINE_EXCEEDED,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_status_code(
|
|
27
|
+
exc: BaseException | None,
|
|
28
|
+
context: Any,
|
|
29
|
+
default: grpc.StatusCode = grpc.StatusCode.UNKNOWN,
|
|
30
|
+
) -> grpc.StatusCode:
|
|
31
|
+
"""Best-effort gRPC status code for a finished or failed RPC.
|
|
32
|
+
|
|
33
|
+
Prefers the exception's own ``code()`` (client-style ``RpcError``); falls
|
|
34
|
+
back to the servicer context's code (set by ``abort()`` / ``set_code()``)
|
|
35
|
+
when the exception carries no code or only a generic ``UNKNOWN``; returns
|
|
36
|
+
``default`` when neither yields a :class:`grpc.StatusCode`.
|
|
37
|
+
"""
|
|
38
|
+
exc_code = _status_code_of_exception(exc)
|
|
39
|
+
if exc_code is not None and exc_code is not grpc.StatusCode.UNKNOWN:
|
|
40
|
+
return exc_code
|
|
41
|
+
|
|
42
|
+
ctx_code = _status_code_of_context(context)
|
|
43
|
+
if ctx_code is not None and ctx_code is not grpc.StatusCode.OK:
|
|
44
|
+
return ctx_code
|
|
45
|
+
if exc_code is not None:
|
|
46
|
+
# UNKNOWN from the exception and nothing better on the context.
|
|
47
|
+
return exc_code
|
|
48
|
+
if ctx_code is not None:
|
|
49
|
+
return ctx_code
|
|
50
|
+
|
|
51
|
+
return default
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _status_code_of_exception(exc: BaseException | None) -> grpc.StatusCode | None:
|
|
55
|
+
if exc is None:
|
|
56
|
+
return None
|
|
57
|
+
code_func = getattr(exc, "code", None)
|
|
58
|
+
if callable(code_func):
|
|
59
|
+
code = code_func()
|
|
60
|
+
if isinstance(code, grpc.StatusCode):
|
|
61
|
+
return code
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _status_code_of_context(context: Any) -> grpc.StatusCode | None:
|
|
66
|
+
code_func = getattr(context, "code", None)
|
|
67
|
+
if not callable(code_func):
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
code = code_func()
|
|
71
|
+
except Exception:
|
|
72
|
+
# Non-conforming context objects fall through to the default.
|
|
73
|
+
return None
|
|
74
|
+
return code if isinstance(code, grpc.StatusCode) else None
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Optional observability wiring for the gRPC server.
|
|
2
|
+
|
|
3
|
+
Submodules are import-gated by their own extras so importing this package never
|
|
4
|
+
pulls heavy dependencies:
|
|
5
|
+
|
|
6
|
+
- ``grpc_server_kit.observability.protocols`` — SDK-free seam protocols (core)
|
|
7
|
+
- ``grpc_server_kit.observability.metrics`` — Prometheus metrics (``[metrics]`` extra)
|
|
8
|
+
- ``grpc_server_kit.observability.sentry`` — Sentry adapter (``[sentry]`` extra)
|
|
9
|
+
|
|
10
|
+
OpenTelemetry instrumentation for the async server lives at
|
|
11
|
+
``grpc_server_kit.aio.observability.tracing`` (``[tracing]`` extra).
|
|
12
|
+
"""
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Prometheus metric definitions for the gRPC server (``[metrics]`` extra).
|
|
2
|
+
|
|
3
|
+
``GrpcServerMetrics`` structurally satisfies
|
|
4
|
+
:class:`grpc_server_kit.aio.interceptors.protocols.GrpcServerMetricsProtocol`, so it can
|
|
5
|
+
be passed straight to ``AsyncMetricsInterceptor``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from grpc_server_kit.observability.naming import make_metric_name
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from prometheus_client import REGISTRY, CollectorRegistry, Counter, Histogram
|
|
14
|
+
|
|
15
|
+
HAS_PROMETHEUS = True
|
|
16
|
+
except ImportError: # pragma: no cover - exercised only when the [metrics] extra is absent
|
|
17
|
+
HAS_PROMETHEUS = False
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DEFAULT_GRPC_BUCKETS",
|
|
21
|
+
"GrpcServerMetrics",
|
|
22
|
+
"get_grpc_server_metrics",
|
|
23
|
+
"make_metric_name",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# Default histogram buckets for gRPC request duration (seconds).
|
|
27
|
+
DEFAULT_GRPC_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
|
|
28
|
+
|
|
29
|
+
_MISSING_MESSAGE = "Install grpc-server-kit[metrics] (prometheus-client) to use gRPC server metrics"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GrpcServerMetrics:
|
|
33
|
+
"""gRPC server metrics.
|
|
34
|
+
|
|
35
|
+
Metrics:
|
|
36
|
+
- ``grpc_requests_total`` (Counter) — labels: service, method, status, grpc_code
|
|
37
|
+
- ``grpc_request_duration_seconds`` (Histogram) — labels: service, method
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
prefix: str | None = None,
|
|
43
|
+
buckets: tuple[float, ...] = DEFAULT_GRPC_BUCKETS,
|
|
44
|
+
registry: CollectorRegistry | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Initialize gRPC server metrics.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
prefix: Optional metric name prefix.
|
|
50
|
+
buckets: Histogram buckets for request duration.
|
|
51
|
+
registry: Collector registry to register with (defaults to the global one).
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ImportError: If prometheus-client is not installed.
|
|
55
|
+
"""
|
|
56
|
+
if not HAS_PROMETHEUS:
|
|
57
|
+
raise ImportError(_MISSING_MESSAGE)
|
|
58
|
+
|
|
59
|
+
self.buckets: tuple[float, ...] = tuple(buckets)
|
|
60
|
+
reg = registry if registry is not None else REGISTRY
|
|
61
|
+
self.requests_total = Counter(
|
|
62
|
+
make_metric_name("grpc_requests_total", prefix),
|
|
63
|
+
"Total number of gRPC requests",
|
|
64
|
+
["service", "method", "status", "grpc_code"],
|
|
65
|
+
registry=reg,
|
|
66
|
+
)
|
|
67
|
+
self.request_duration = Histogram(
|
|
68
|
+
make_metric_name("grpc_request_duration_seconds", prefix),
|
|
69
|
+
"gRPC request duration in seconds",
|
|
70
|
+
["service", "method"],
|
|
71
|
+
buckets=list(buckets),
|
|
72
|
+
registry=reg,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def record_request(
|
|
76
|
+
self,
|
|
77
|
+
service: str,
|
|
78
|
+
method: str,
|
|
79
|
+
status: str,
|
|
80
|
+
grpc_code: str,
|
|
81
|
+
duration: float,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Record a completed gRPC request."""
|
|
84
|
+
self.requests_total.labels(service=service, method=method, status=status, grpc_code=grpc_code).inc()
|
|
85
|
+
self.request_duration.labels(service=service, method=method).observe(duration)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_SERVER_METRICS_CACHE: dict[str | None, GrpcServerMetrics] = {}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_grpc_server_metrics(
|
|
92
|
+
prefix: str | None = None,
|
|
93
|
+
buckets: tuple[float, ...] | None = None,
|
|
94
|
+
) -> GrpcServerMetrics:
|
|
95
|
+
"""Get (or lazily create) a cached ``GrpcServerMetrics`` for the given prefix.
|
|
96
|
+
|
|
97
|
+
Caching by prefix avoids Prometheus "duplicated timeseries" errors when the helper
|
|
98
|
+
is called more than once for the same prefix.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
ValueError: If the prefix is already cached with DIFFERENT buckets —
|
|
102
|
+
silently returning the old instance would record latencies into the
|
|
103
|
+
wrong histogram bounds with no error.
|
|
104
|
+
"""
|
|
105
|
+
requested = tuple(buckets) if buckets is not None else DEFAULT_GRPC_BUCKETS
|
|
106
|
+
cached = _SERVER_METRICS_CACHE.get(prefix)
|
|
107
|
+
if cached is not None:
|
|
108
|
+
if buckets is not None and requested != cached.buckets:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"GrpcServerMetrics for prefix {prefix!r} already exists with buckets "
|
|
111
|
+
f"{cached.buckets}; cannot re-create it with {requested}"
|
|
112
|
+
)
|
|
113
|
+
return cached
|
|
114
|
+
metrics = GrpcServerMetrics(prefix=prefix, buckets=requested)
|
|
115
|
+
_SERVER_METRICS_CACHE[prefix] = metrics
|
|
116
|
+
return metrics
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Metric naming helpers (pure stdlib, backend-neutral)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_PREFIX_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def make_metric_name(name: str, prefix: str | None = None) -> str:
|
|
11
|
+
"""Combine ``prefix`` and ``name`` per metric identifier conventions.
|
|
12
|
+
|
|
13
|
+
Raises:
|
|
14
|
+
ValueError: If the prefix is not a valid metric name fragment.
|
|
15
|
+
"""
|
|
16
|
+
if prefix:
|
|
17
|
+
if not _PREFIX_PATTERN.match(prefix):
|
|
18
|
+
raise ValueError(
|
|
19
|
+
f"Invalid metric prefix: {prefix!r}. Prefixes must start with a letter or "
|
|
20
|
+
"underscore and contain only letters, numbers, or underscores."
|
|
21
|
+
)
|
|
22
|
+
return f"{prefix}_{name}"
|
|
23
|
+
return name
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["make_metric_name"]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Observability seam protocols (duck-typing, SDK-free).
|
|
2
|
+
|
|
3
|
+
Pure structural protocols that the kit's interceptors call without knowing the
|
|
4
|
+
concrete backend. The names are vendor-neutral: an OpenTelemetry tracer, the
|
|
5
|
+
Sentry SDK adapter, or any test double with the same shape fits. Downstream
|
|
6
|
+
frameworks (e.g. servicewright) adapt to these shapes — protocols match
|
|
7
|
+
structurally, so no import dependency is required in either direction.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from contextlib import AbstractContextManager
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
type SpanAttributeValue = str | int | float | bool | list[str] | list[int] | list[float] | list[bool]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class SpanProtocol(Protocol):
|
|
21
|
+
"""A tracing span context manager."""
|
|
22
|
+
|
|
23
|
+
def set_attribute(self, key: str, value: SpanAttributeValue) -> None:
|
|
24
|
+
"""Attach a typed attribute to the span."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def record_exception(self, exception: Exception) -> None:
|
|
28
|
+
"""Record an exception against the span."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def __enter__(self) -> SpanProtocol: ...
|
|
32
|
+
|
|
33
|
+
def __exit__(
|
|
34
|
+
self,
|
|
35
|
+
exc_type: type[BaseException] | None,
|
|
36
|
+
exc_value: BaseException | None,
|
|
37
|
+
traceback: TracebackType | None,
|
|
38
|
+
) -> None: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@runtime_checkable
|
|
42
|
+
class TracerProtocol(Protocol):
|
|
43
|
+
"""Mints spans for the active trace."""
|
|
44
|
+
|
|
45
|
+
def start_as_current_span(self, name: str) -> SpanProtocol:
|
|
46
|
+
"""Start a span and make it current for its ``with`` block."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ErrorReporterProtocol(Protocol):
|
|
51
|
+
"""Error-reporting seam (vendor-neutral; any tracker with this shape fits)."""
|
|
52
|
+
|
|
53
|
+
def isolate(self) -> AbstractContextManager[object]:
|
|
54
|
+
"""Return a context manager forking an isolated per-unit scope.
|
|
55
|
+
|
|
56
|
+
Tags and breadcrumbs set inside the block must not leak into other
|
|
57
|
+
concurrent units of work. Backends without scope isolation may return
|
|
58
|
+
``contextlib.nullcontext()``.
|
|
59
|
+
"""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
def capture_exception(self, error: Exception) -> None:
|
|
63
|
+
"""Report an exception to the error tracker."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
def add_breadcrumb(
|
|
67
|
+
self,
|
|
68
|
+
message: str,
|
|
69
|
+
category: str = "default",
|
|
70
|
+
level: str = "info",
|
|
71
|
+
data: dict[str, object] | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Attach a breadcrumb to the current scope."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
def set_tags(self, **tags: str) -> None:
|
|
77
|
+
"""Set tags on the current scope."""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
"ErrorReporterProtocol",
|
|
83
|
+
"SpanAttributeValue",
|
|
84
|
+
"SpanProtocol",
|
|
85
|
+
"TracerProtocol",
|
|
86
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Sentry adapter implementing the kit's ``ErrorReporterProtocol`` over ``sentry-sdk``.
|
|
2
|
+
|
|
3
|
+
Requires the ``[sentry]`` extra. Pass a ``SentrySdkAdapter`` instance to
|
|
4
|
+
``AsyncSentryInterceptor`` to report unexpected gRPC errors to Sentry.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from contextlib import AbstractContextManager
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import sentry_sdk
|
|
16
|
+
|
|
17
|
+
HAS_SENTRY = True
|
|
18
|
+
except ImportError: # pragma: no cover - exercised only when the [sentry] extra is absent
|
|
19
|
+
HAS_SENTRY = False
|
|
20
|
+
|
|
21
|
+
__all__ = ["SentrySdkAdapter"]
|
|
22
|
+
|
|
23
|
+
_MISSING_MESSAGE = "Install grpc-server-kit[sentry] (sentry-sdk) to use the Sentry adapter"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SentrySdkAdapter:
|
|
27
|
+
"""Adapts the global ``sentry-sdk`` API to ``ErrorReporterProtocol``."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
"""Initialize the adapter.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ImportError: If sentry-sdk is not installed.
|
|
34
|
+
"""
|
|
35
|
+
if not HAS_SENTRY:
|
|
36
|
+
raise ImportError(_MISSING_MESSAGE)
|
|
37
|
+
|
|
38
|
+
def isolate(self) -> AbstractContextManager[object]:
|
|
39
|
+
"""Fork an isolated Sentry scope for one unit of work.
|
|
40
|
+
|
|
41
|
+
Without this, concurrent asyncio tasks share one isolation scope and
|
|
42
|
+
contaminate each other's tags/breadcrumbs.
|
|
43
|
+
"""
|
|
44
|
+
return sentry_sdk.isolation_scope()
|
|
45
|
+
|
|
46
|
+
def add_breadcrumb(
|
|
47
|
+
self,
|
|
48
|
+
message: str,
|
|
49
|
+
category: str = "default",
|
|
50
|
+
level: str = "info",
|
|
51
|
+
data: dict[str, object] | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Add a breadcrumb to the current Sentry scope."""
|
|
54
|
+
sentry_sdk.add_breadcrumb(message=message, category=category, level=level, data=data or {})
|
|
55
|
+
|
|
56
|
+
def capture_exception(self, error: Exception) -> None:
|
|
57
|
+
"""Capture an exception in Sentry."""
|
|
58
|
+
sentry_sdk.capture_exception(error)
|
|
59
|
+
|
|
60
|
+
def set_tags(self, **tags: str) -> None:
|
|
61
|
+
"""Set tags on the current Sentry scope."""
|
|
62
|
+
for key, value in tags.items():
|
|
63
|
+
sentry_sdk.set_tag(key, value)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""gRPC server options and compression configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .constants import (
|
|
6
|
+
DEFAULT_HTTP2_MAX_PINGS_WITHOUT_DATA,
|
|
7
|
+
DEFAULT_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS,
|
|
8
|
+
DEFAULT_INITIAL_WINDOW_SIZE,
|
|
9
|
+
DEFAULT_KEEPALIVE_TIME_MS,
|
|
10
|
+
DEFAULT_KEEPALIVE_TIMEOUT_MS,
|
|
11
|
+
DEFAULT_MAX_METADATA_SIZE,
|
|
12
|
+
DEFAULT_MAX_RECEIVE_MESSAGE_LENGTH,
|
|
13
|
+
DEFAULT_MAX_SEND_MESSAGE_LENGTH,
|
|
14
|
+
)
|
|
15
|
+
from .protocols import GrpcSettingsProtocol
|
|
16
|
+
|
|
17
|
+
type GrpcOption = tuple[str, int]
|
|
18
|
+
type GrpcOptions = list[GrpcOption]
|
|
19
|
+
|
|
20
|
+
COMPRESSION_ALGORITHMS = {
|
|
21
|
+
"none": 0,
|
|
22
|
+
"deflate": 1,
|
|
23
|
+
"gzip": 2,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_grpc_options(settings: GrpcSettingsProtocol) -> GrpcOptions:
|
|
28
|
+
"""Build gRPC server options from settings.
|
|
29
|
+
|
|
30
|
+
Settings fields set to ``None`` fall back to the kit's defaults; invalid
|
|
31
|
+
values fail loudly — a misconfigured server must not start.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
settings: gRPC configuration settings following GrpcSettingsProtocol
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of tuples containing gRPC channel options
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: If any option value is negative or the compression
|
|
41
|
+
algorithm is not supported.
|
|
42
|
+
"""
|
|
43
|
+
options: GrpcOptions = [
|
|
44
|
+
# Message size
|
|
45
|
+
(
|
|
46
|
+
"grpc.max_send_message_length",
|
|
47
|
+
_require_option(
|
|
48
|
+
settings.max_send_message_length, "max_send_message_length", DEFAULT_MAX_SEND_MESSAGE_LENGTH
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
(
|
|
52
|
+
"grpc.max_receive_message_length",
|
|
53
|
+
_require_option(
|
|
54
|
+
settings.max_receive_message_length, "max_receive_message_length", DEFAULT_MAX_RECEIVE_MESSAGE_LENGTH
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
(
|
|
58
|
+
"grpc.max_metadata_size",
|
|
59
|
+
_require_option(settings.max_metadata_size, "max_metadata_size", DEFAULT_MAX_METADATA_SIZE),
|
|
60
|
+
),
|
|
61
|
+
# Keepalive
|
|
62
|
+
(
|
|
63
|
+
"grpc.keepalive_time_ms",
|
|
64
|
+
_require_option(settings.keepalive_time_ms, "keepalive_time_ms", DEFAULT_KEEPALIVE_TIME_MS),
|
|
65
|
+
),
|
|
66
|
+
(
|
|
67
|
+
"grpc.keepalive_timeout_ms",
|
|
68
|
+
_require_option(settings.keepalive_timeout_ms, "keepalive_timeout_ms", DEFAULT_KEEPALIVE_TIMEOUT_MS),
|
|
69
|
+
),
|
|
70
|
+
("grpc.keepalive_permit_without_calls", int(settings.keepalive_permit_without_calls)),
|
|
71
|
+
(
|
|
72
|
+
"grpc.http2.min_recv_ping_interval_without_data_ms",
|
|
73
|
+
_require_option(
|
|
74
|
+
settings.http2_min_recv_ping_interval_without_data_ms,
|
|
75
|
+
"http2_min_recv_ping_interval_without_data_ms",
|
|
76
|
+
DEFAULT_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS,
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
(
|
|
80
|
+
"grpc.http2.max_pings_without_data",
|
|
81
|
+
_require_option(
|
|
82
|
+
settings.http2_max_pings_without_data,
|
|
83
|
+
"http2_max_pings_without_data",
|
|
84
|
+
DEFAULT_HTTP2_MAX_PINGS_WITHOUT_DATA,
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
# Flow control
|
|
88
|
+
(
|
|
89
|
+
"grpc.http2.initial_stream_window_size",
|
|
90
|
+
_require_option(
|
|
91
|
+
settings.initial_stream_window_size, "initial_stream_window_size", DEFAULT_INITIAL_WINDOW_SIZE
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
(
|
|
95
|
+
"grpc.http2.initial_connection_window_size",
|
|
96
|
+
_require_option(
|
|
97
|
+
settings.initial_connection_window_size, "initial_connection_window_size", DEFAULT_INITIAL_WINDOW_SIZE
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
# Optional: connection limits (omitted entirely when not configured)
|
|
103
|
+
for option_name, value, setting_name in (
|
|
104
|
+
("grpc.max_concurrent_streams", settings.max_concurrent_rpcs, "max_concurrent_rpcs"),
|
|
105
|
+
("grpc.max_connection_idle_ms", settings.max_connection_idle_ms, "max_connection_idle_ms"),
|
|
106
|
+
("grpc.max_connection_age_ms", settings.max_connection_age_ms, "max_connection_age_ms"),
|
|
107
|
+
("grpc.max_connection_age_grace_ms", settings.max_connection_age_grace_ms, "max_connection_age_grace_ms"),
|
|
108
|
+
):
|
|
109
|
+
if value is not None:
|
|
110
|
+
options.append((option_name, _require_non_negative(value, setting_name)))
|
|
111
|
+
|
|
112
|
+
# Compression
|
|
113
|
+
if settings.compression_algorithm:
|
|
114
|
+
algo = settings.compression_algorithm.lower()
|
|
115
|
+
if algo not in COMPRESSION_ALGORITHMS:
|
|
116
|
+
supported = ", ".join(sorted(COMPRESSION_ALGORITHMS))
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"Unsupported compression_algorithm: {settings.compression_algorithm!r}. Supported: {supported}"
|
|
119
|
+
)
|
|
120
|
+
options.append(("grpc.default_compression_algorithm", COMPRESSION_ALGORITHMS[algo]))
|
|
121
|
+
|
|
122
|
+
return options
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _require_option(value: int | None, name: str, default: int) -> int:
|
|
126
|
+
"""Return ``default`` when unset; reject negative values loudly."""
|
|
127
|
+
if value is None:
|
|
128
|
+
return default
|
|
129
|
+
return _require_non_negative(value, name)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _require_non_negative(value: int, name: str) -> int:
|
|
133
|
+
"""Validate that a configured option value is non-negative."""
|
|
134
|
+
if value < 0:
|
|
135
|
+
raise ValueError(f"{name} must be non-negative, got {value}")
|
|
136
|
+
return value
|