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,104 @@
|
|
|
1
|
+
"""Metrics interceptor for gRPC (backend-neutral recorder seam)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
import grpc
|
|
11
|
+
|
|
12
|
+
from grpc_server_kit.interceptors.constants import SKIPPED_HEALTH_METHODS
|
|
13
|
+
from grpc_server_kit.interceptors.utils import resolve_status_code
|
|
14
|
+
|
|
15
|
+
from .base import AsyncServerInterceptor, RpcCall
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import AsyncIterator, Collection
|
|
19
|
+
|
|
20
|
+
from .protocols import GrpcServerMetricsProtocol
|
|
21
|
+
|
|
22
|
+
__all__ = ["AsyncMetricsInterceptor"]
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AsyncMetricsInterceptor(AsyncServerInterceptor):
|
|
28
|
+
"""Interceptor that records gRPC request metrics via a metrics recorder.
|
|
29
|
+
|
|
30
|
+
Recording is delegated to any object satisfying ``GrpcServerMetricsProtocol``,
|
|
31
|
+
so the kit never imports a metrics backend directly. Skips gRPC health check
|
|
32
|
+
methods by default, and metric-recording failures never fail the request.
|
|
33
|
+
For streaming RPCs the recorded duration covers the whole stream.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
metrics: GrpcServerMetricsProtocol | None = None,
|
|
39
|
+
service_name: str = "unknown",
|
|
40
|
+
*,
|
|
41
|
+
skip_methods: Collection[str] = SKIPPED_HEALTH_METHODS,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Initialize interceptor with the metrics recorder and service name.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
metrics: A ``GrpcServerMetricsProtocol`` implementation or None.
|
|
47
|
+
service_name: The name of the service to include in labels.
|
|
48
|
+
skip_methods: Full RPC method names to exclude from metrics
|
|
49
|
+
(defaults to the gRPC health-check methods).
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If ``service_name`` is empty.
|
|
53
|
+
"""
|
|
54
|
+
super().__init__(skip_methods=skip_methods)
|
|
55
|
+
if not service_name:
|
|
56
|
+
raise ValueError("service_name cannot be empty")
|
|
57
|
+
self._service_name = service_name
|
|
58
|
+
self._metrics = metrics
|
|
59
|
+
|
|
60
|
+
async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
|
|
61
|
+
"""Record request count and duration around the RPC."""
|
|
62
|
+
if self._metrics is None:
|
|
63
|
+
yield
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
start_time = time.perf_counter()
|
|
67
|
+
status = "success"
|
|
68
|
+
grpc_code = grpc.StatusCode.OK
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
yield
|
|
72
|
+
except BaseException as exc:
|
|
73
|
+
status = "error"
|
|
74
|
+
grpc_code = self._classify_error(exc, call)
|
|
75
|
+
raise
|
|
76
|
+
else:
|
|
77
|
+
grpc_code = resolve_status_code(None, call.context, default=grpc.StatusCode.OK)
|
|
78
|
+
finally:
|
|
79
|
+
duration = time.perf_counter() - start_time
|
|
80
|
+
try:
|
|
81
|
+
self._metrics.record_request(
|
|
82
|
+
service=self._service_name,
|
|
83
|
+
method=call.method_name,
|
|
84
|
+
status=status,
|
|
85
|
+
grpc_code=grpc_code.name,
|
|
86
|
+
duration=duration,
|
|
87
|
+
)
|
|
88
|
+
except Exception:
|
|
89
|
+
# Metrics errors should not fail the request
|
|
90
|
+
logger.exception("Failed to record gRPC server metrics")
|
|
91
|
+
|
|
92
|
+
def _classify_error(self, exc: BaseException, call: RpcCall) -> grpc.StatusCode:
|
|
93
|
+
"""Resolve the status code to record for a failed RPC."""
|
|
94
|
+
if isinstance(exc, asyncio.CancelledError | GeneratorExit):
|
|
95
|
+
# Task cancellation / client gone mid-stream.
|
|
96
|
+
return grpc.StatusCode.CANCELLED
|
|
97
|
+
if isinstance(exc, grpc.aio.AbortError):
|
|
98
|
+
# Deliberate abort — the context carries the real status.
|
|
99
|
+
return resolve_status_code(None, call.context)
|
|
100
|
+
code = resolve_status_code(exc if isinstance(exc, grpc.RpcError) else None, call.context)
|
|
101
|
+
if code in (grpc.StatusCode.OK, grpc.StatusCode.UNKNOWN) and not isinstance(exc, grpc.RpcError):
|
|
102
|
+
# An exception with no explicit status is an internal error.
|
|
103
|
+
return grpc.StatusCode.INTERNAL
|
|
104
|
+
return code
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Structural protocols for optional interceptor dependencies (duck-typing seams).
|
|
2
|
+
|
|
3
|
+
The cross-cutting observability seams (span/tracer, error reporter) are owned by
|
|
4
|
+
:mod:`grpc_server_kit.observability.protocols` and re-exported here for
|
|
5
|
+
interceptor consumers. The protocols defined locally are gRPC-specific shapes
|
|
6
|
+
that belong to this package.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from grpc_server_kit.observability.protocols import (
|
|
14
|
+
ErrorReporterProtocol,
|
|
15
|
+
SpanAttributeValue,
|
|
16
|
+
SpanProtocol,
|
|
17
|
+
TracerProtocol,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GrpcServerMetricsProtocol(Protocol):
|
|
22
|
+
"""Protocol for a gRPC server metrics recorder.
|
|
23
|
+
|
|
24
|
+
The kit's prometheus-backed ``GrpcServerMetrics`` satisfies this
|
|
25
|
+
structurally, as does any recorder with the same shape.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def record_request(
|
|
29
|
+
self,
|
|
30
|
+
service: str,
|
|
31
|
+
method: str,
|
|
32
|
+
status: str,
|
|
33
|
+
grpc_code: str,
|
|
34
|
+
duration: float,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Record a completed gRPC request.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
service: Name of the service.
|
|
40
|
+
method: Name of the method.
|
|
41
|
+
status: Status of the request (success, error).
|
|
42
|
+
grpc_code: gRPC status code name.
|
|
43
|
+
duration: Request duration in seconds.
|
|
44
|
+
"""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"ErrorReporterProtocol",
|
|
50
|
+
"GrpcServerMetricsProtocol",
|
|
51
|
+
"SpanAttributeValue",
|
|
52
|
+
"SpanProtocol",
|
|
53
|
+
"TracerProtocol",
|
|
54
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Request logger interceptor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
import grpc
|
|
10
|
+
|
|
11
|
+
from grpc_server_kit.interceptors.constants import SKIPPED_HEALTH_METHODS
|
|
12
|
+
from grpc_server_kit.interceptors.utils import is_server_error, resolve_status_code
|
|
13
|
+
|
|
14
|
+
from .base import AsyncServerInterceptor, RpcCall
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import AsyncIterator, Collection
|
|
18
|
+
|
|
19
|
+
__all__ = ["AsyncRequestLoggerInterceptor"]
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sanitize_peer(peer: str | None) -> str:
|
|
25
|
+
"""Sanitize peer address to avoid logging internal IPs."""
|
|
26
|
+
if not peer:
|
|
27
|
+
return "unknown"
|
|
28
|
+
# Log only the protocol, hide the actual IP
|
|
29
|
+
for proto in ("ipv4", "ipv6", "unix"):
|
|
30
|
+
if peer.startswith(f"{proto}:"):
|
|
31
|
+
return proto
|
|
32
|
+
return "unknown"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncRequestLoggerInterceptor(AsyncServerInterceptor):
|
|
36
|
+
"""Interceptor that logs start and completion of gRPC requests.
|
|
37
|
+
|
|
38
|
+
Logs via standard logging with ``extra=`` for method and optional peer protocol.
|
|
39
|
+
Skips logging for health check methods. For streaming RPCs the completion log
|
|
40
|
+
is emitted after the full stream has been produced.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
log_peer: bool = False,
|
|
47
|
+
log_request_on_error: bool = False,
|
|
48
|
+
skip_methods: Collection[str] = SKIPPED_HEALTH_METHODS,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Initialize interceptor.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
log_peer: Whether to log the peer protocol of the gRPC request.
|
|
54
|
+
log_request_on_error: Whether to include ``str(request)`` in server-error
|
|
55
|
+
logs. Off by default — request payloads may contain sensitive data.
|
|
56
|
+
skip_methods: Full RPC method names to exclude from logging
|
|
57
|
+
(defaults to the gRPC health-check methods).
|
|
58
|
+
"""
|
|
59
|
+
super().__init__(skip_methods=skip_methods)
|
|
60
|
+
self._log_peer = log_peer
|
|
61
|
+
self._log_request_on_error = log_request_on_error
|
|
62
|
+
|
|
63
|
+
async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
|
|
64
|
+
"""Log request start/completion/failure around the RPC."""
|
|
65
|
+
log_data: dict[str, Any] = {"method": call.method_name}
|
|
66
|
+
if self._log_peer:
|
|
67
|
+
log_data["peer_protocol"] = _sanitize_peer(call.context.peer())
|
|
68
|
+
|
|
69
|
+
logger.info("gRPC request started", extra=log_data)
|
|
70
|
+
try:
|
|
71
|
+
yield
|
|
72
|
+
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
|
|
73
|
+
logger.info("gRPC request cancelled", extra={"method": call.method_name})
|
|
74
|
+
raise
|
|
75
|
+
except grpc.aio.AbortError:
|
|
76
|
+
# An interceptor or the handler aborted with an explicit status; the
|
|
77
|
+
# context carries the real code. No stack trace — the abort was deliberate.
|
|
78
|
+
status = resolve_status_code(None, call.context)
|
|
79
|
+
self._log_failure(call, status, exc_info=False)
|
|
80
|
+
raise
|
|
81
|
+
except grpc.RpcError as exc:
|
|
82
|
+
status = resolve_status_code(exc, call.context)
|
|
83
|
+
self._log_failure(call, status, exc_info=is_server_error(status))
|
|
84
|
+
raise
|
|
85
|
+
except Exception:
|
|
86
|
+
# Unexpected exceptions that are not gRPC errors yet: log with stack trace.
|
|
87
|
+
extra: dict[str, Any] = {"method": call.method_name}
|
|
88
|
+
if self._log_request_on_error:
|
|
89
|
+
extra["request"] = str(call.request)
|
|
90
|
+
logger.exception("gRPC request failed (unexpected exception)", extra=extra)
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
logger.info("gRPC request completed", extra={"method": call.method_name})
|
|
94
|
+
|
|
95
|
+
def _log_failure(self, call: RpcCall, status: grpc.StatusCode, *, exc_info: bool) -> None:
|
|
96
|
+
"""Log a failed RPC at a severity matching the status code."""
|
|
97
|
+
extra: dict[str, Any] = {"method": call.method_name, "status": status.name}
|
|
98
|
+
if is_server_error(status):
|
|
99
|
+
if self._log_request_on_error:
|
|
100
|
+
extra["request"] = str(call.request)
|
|
101
|
+
logger.error("gRPC request failed (server error)", extra=extra, exc_info=exc_info)
|
|
102
|
+
else:
|
|
103
|
+
logger.info("gRPC request failed (client error)", extra=extra)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Sentry interceptor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import grpc
|
|
9
|
+
|
|
10
|
+
from grpc_server_kit.interceptors.constants import (
|
|
11
|
+
METHOD_KEY,
|
|
12
|
+
REQUEST_ID_KEY,
|
|
13
|
+
SENTRY_GRPC_METHOD,
|
|
14
|
+
SENTRY_REQUEST_ID,
|
|
15
|
+
SKIPPED_HEALTH_METHODS,
|
|
16
|
+
X_REQUEST_ID,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .base import AsyncServerInterceptor, RpcCall
|
|
20
|
+
from .metadata import get_metadata_dict
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import AsyncIterator, Callable, Collection
|
|
24
|
+
|
|
25
|
+
from .protocols import ErrorReporterProtocol
|
|
26
|
+
|
|
27
|
+
__all__ = ["AsyncSentryInterceptor"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AsyncSentryInterceptor(AsyncServerInterceptor):
|
|
31
|
+
"""Interceptor that adds error tracking and breadcrumbs.
|
|
32
|
+
|
|
33
|
+
Each RPC runs inside the reporter's isolated scope (``reporter.isolate()``),
|
|
34
|
+
so tags and breadcrumbs of concurrent RPCs never contaminate each other's
|
|
35
|
+
reports. Health-check methods are skipped by default.
|
|
36
|
+
|
|
37
|
+
Place this interceptor INSIDE the exception handler in the chain (later in
|
|
38
|
+
the interceptor list) so it observes raw handler exceptions before they are
|
|
39
|
+
mapped into ``grpc.aio.AbortError``. Deliberate aborts and gRPC errors are
|
|
40
|
+
re-raised without capture.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
sentry: ErrorReporterProtocol | None = None,
|
|
46
|
+
*,
|
|
47
|
+
capture_filter: Callable[[Exception], bool] | None = None,
|
|
48
|
+
skip_methods: Collection[str] = SKIPPED_HEALTH_METHODS,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Initialize interceptor.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
sentry: An ``ErrorReporterProtocol`` implementation or None (no-op).
|
|
54
|
+
capture_filter: Predicate deciding whether an unexpected exception
|
|
55
|
+
is captured. None captures everything. The dishka bundle wires
|
|
56
|
+
a filter capturing only server-class errors (per the same
|
|
57
|
+
mapping the exception handler uses), so client-mapped errors
|
|
58
|
+
like ``ValueError`` do not spam the error tracker.
|
|
59
|
+
skip_methods: Full RPC method names to exclude from error tracking
|
|
60
|
+
(defaults to the gRPC health-check methods).
|
|
61
|
+
"""
|
|
62
|
+
super().__init__(skip_methods=skip_methods)
|
|
63
|
+
self._sentry = sentry
|
|
64
|
+
self._capture_filter = capture_filter
|
|
65
|
+
|
|
66
|
+
async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
|
|
67
|
+
"""Tag the isolated scope, add a breadcrumb, and capture unexpected exceptions."""
|
|
68
|
+
if self._sentry is None:
|
|
69
|
+
yield
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
metadata = get_metadata_dict(call.context)
|
|
73
|
+
request_id = str(metadata.get(X_REQUEST_ID, ""))
|
|
74
|
+
|
|
75
|
+
with self._sentry.isolate():
|
|
76
|
+
self._sentry.set_tags(
|
|
77
|
+
**{
|
|
78
|
+
SENTRY_GRPC_METHOD: call.method_name,
|
|
79
|
+
SENTRY_REQUEST_ID: request_id,
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
self._sentry.add_breadcrumb(
|
|
83
|
+
message=f"gRPC call: {call.method_name}",
|
|
84
|
+
category="grpc",
|
|
85
|
+
level="info",
|
|
86
|
+
data={METHOD_KEY: call.method_name, REQUEST_ID_KEY: request_id},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
yield
|
|
91
|
+
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
|
|
92
|
+
raise
|
|
93
|
+
except grpc.aio.AbortError:
|
|
94
|
+
# Deliberate abort with an explicit status — not an unexpected error.
|
|
95
|
+
raise
|
|
96
|
+
except grpc.RpcError:
|
|
97
|
+
# Re-raised gRPC errors are handled by the exception handler interceptor.
|
|
98
|
+
raise
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
if self._capture_filter is None or self._capture_filter(exc):
|
|
101
|
+
self._sentry.capture_exception(exc)
|
|
102
|
+
raise
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Tracing interceptor (OpenTelemetry-compatible seam).
|
|
2
|
+
|
|
3
|
+
Creates one span per RPC with OTel semantic-convention attributes
|
|
4
|
+
(``rpc.system``, ``rpc.service``, ``rpc.method``, ``rpc.grpc.status_code``).
|
|
5
|
+
Tracing is delegated to any object satisfying ``TracerProtocol`` — the
|
|
6
|
+
OpenTelemetry tracer fits structurally.
|
|
7
|
+
|
|
8
|
+
Note:
|
|
9
|
+
This interceptor is a lightweight seam and does not extract the incoming
|
|
10
|
+
trace context (``traceparent``) from metadata. For full distributed-tracing
|
|
11
|
+
propagation use the official instrumentation via
|
|
12
|
+
:func:`grpc_server_kit.aio.observability.instrument_aio_server` (the
|
|
13
|
+
``[tracing]`` extra) — the two approaches compose.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
import grpc
|
|
22
|
+
|
|
23
|
+
from grpc_server_kit.interceptors.constants import (
|
|
24
|
+
OTEL_REQUEST_ID,
|
|
25
|
+
OTEL_RPC_GRPC_STATUS_CODE,
|
|
26
|
+
OTEL_RPC_METHOD,
|
|
27
|
+
OTEL_RPC_SERVICE,
|
|
28
|
+
OTEL_RPC_SYSTEM,
|
|
29
|
+
SKIPPED_HEALTH_METHODS,
|
|
30
|
+
X_REQUEST_ID,
|
|
31
|
+
)
|
|
32
|
+
from grpc_server_kit.interceptors.utils import is_server_error, resolve_status_code
|
|
33
|
+
|
|
34
|
+
from .base import AsyncServerInterceptor, RpcCall, split_method_name
|
|
35
|
+
from .metadata import get_metadata_dict
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from collections.abc import AsyncIterator, Collection
|
|
39
|
+
|
|
40
|
+
from .protocols import SpanProtocol, TracerProtocol
|
|
41
|
+
|
|
42
|
+
__all__ = ["AsyncTracingInterceptor"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AsyncTracingInterceptor(AsyncServerInterceptor):
|
|
46
|
+
"""Interceptor that adds a tracing span to each gRPC call.
|
|
47
|
+
|
|
48
|
+
The span covers the whole RPC (including full response streams), is named
|
|
49
|
+
after the RPC method, and carries OTel semconv attributes. Health-check
|
|
50
|
+
methods are skipped by default. Exceptions are recorded against the span
|
|
51
|
+
for server-side errors — including errors already mapped to an abort by an
|
|
52
|
+
inner exception handler (recovered from ``AbortError.__context__``).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
service_name: str,
|
|
58
|
+
tracer: TracerProtocol | None = None,
|
|
59
|
+
*,
|
|
60
|
+
skip_methods: Collection[str] = SKIPPED_HEALTH_METHODS,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Initialize interceptor with the service name and tracer.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
service_name: Fallback service name for span attributes when the RPC
|
|
66
|
+
method name cannot be parsed.
|
|
67
|
+
tracer: A ``TracerProtocol`` implementation or None (no-op).
|
|
68
|
+
skip_methods: Full RPC method names to exclude from tracing
|
|
69
|
+
(defaults to the gRPC health-check methods).
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: If ``service_name`` is empty.
|
|
73
|
+
"""
|
|
74
|
+
super().__init__(skip_methods=skip_methods)
|
|
75
|
+
if not service_name:
|
|
76
|
+
raise ValueError("service_name cannot be empty")
|
|
77
|
+
self._service_name = service_name
|
|
78
|
+
self.tracer = tracer
|
|
79
|
+
|
|
80
|
+
async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
|
|
81
|
+
"""Wrap the RPC in a span with semconv attributes."""
|
|
82
|
+
if self.tracer is None:
|
|
83
|
+
yield
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
service, method = split_method_name(call.method_name)
|
|
87
|
+
span_name = call.method_name.removeprefix("/") or call.method_name
|
|
88
|
+
|
|
89
|
+
with self.tracer.start_as_current_span(span_name) as span:
|
|
90
|
+
span.set_attribute(OTEL_RPC_SYSTEM, "grpc")
|
|
91
|
+
span.set_attribute(OTEL_RPC_SERVICE, service or self._service_name)
|
|
92
|
+
span.set_attribute(OTEL_RPC_METHOD, method or call.method_name)
|
|
93
|
+
|
|
94
|
+
metadata = get_metadata_dict(call.context)
|
|
95
|
+
if X_REQUEST_ID in metadata:
|
|
96
|
+
span.set_attribute(OTEL_REQUEST_ID, metadata[X_REQUEST_ID])
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
yield
|
|
100
|
+
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
|
|
101
|
+
self._set_status(span, grpc.StatusCode.CANCELLED)
|
|
102
|
+
raise
|
|
103
|
+
except grpc.aio.AbortError as exc:
|
|
104
|
+
status = resolve_status_code(None, call.context)
|
|
105
|
+
self._set_status(span, status)
|
|
106
|
+
if is_server_error(status):
|
|
107
|
+
# An inner exception handler already mapped the original
|
|
108
|
+
# exception into this abort; recover it from the chain so
|
|
109
|
+
# server-error spans still carry the real exception event.
|
|
110
|
+
cause = exc.__cause__ or exc.__context__
|
|
111
|
+
if isinstance(cause, Exception) and not isinstance(cause, grpc.aio.AbortError):
|
|
112
|
+
span.record_exception(cause)
|
|
113
|
+
raise
|
|
114
|
+
except grpc.RpcError as exc:
|
|
115
|
+
status = resolve_status_code(exc, call.context)
|
|
116
|
+
self._set_status(span, status)
|
|
117
|
+
if is_server_error(status):
|
|
118
|
+
span.record_exception(exc)
|
|
119
|
+
raise
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
self._set_status(span, resolve_status_code(None, call.context, default=grpc.StatusCode.INTERNAL))
|
|
122
|
+
span.record_exception(exc)
|
|
123
|
+
raise
|
|
124
|
+
else:
|
|
125
|
+
self._set_status(span, resolve_status_code(None, call.context, default=grpc.StatusCode.OK))
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _set_status(span: SpanProtocol, status: grpc.StatusCode) -> None:
|
|
129
|
+
"""Set the numeric ``rpc.grpc.status_code`` semconv attribute."""
|
|
130
|
+
span.set_attribute(OTEL_RPC_GRPC_STATUS_CODE, status.value[0])
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""OpenTelemetry tracing wiring for the async gRPC server (``[tracing]`` extra).
|
|
2
|
+
|
|
3
|
+
Thin helpers over ``GrpcAioInstrumentorServer``. Call :func:`instrument_aio_server` once
|
|
4
|
+
at startup (optionally passing ``tracer_provider=...``, ``filter_=...`` etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from opentelemetry.instrumentation.grpc import GrpcAioInstrumentorServer
|
|
13
|
+
|
|
14
|
+
HAS_OTEL_GRPC = True
|
|
15
|
+
except ImportError: # pragma: no cover - exercised only when the [tracing] extra is absent
|
|
16
|
+
HAS_OTEL_GRPC = False
|
|
17
|
+
|
|
18
|
+
__all__ = ["instrument_aio_server", "uninstrument_aio_server"]
|
|
19
|
+
|
|
20
|
+
_MISSING_MESSAGE = (
|
|
21
|
+
"Install grpc-server-kit[tracing] (opentelemetry-instrumentation-grpc) "
|
|
22
|
+
"to instrument the gRPC server with OpenTelemetry"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def instrument_aio_server(**kwargs: Any) -> None:
|
|
27
|
+
"""Instrument the async gRPC server with OpenTelemetry.
|
|
28
|
+
|
|
29
|
+
Keyword arguments are forwarded to ``GrpcAioInstrumentorServer().instrument()``
|
|
30
|
+
(e.g. ``tracer_provider``, ``filter_``).
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ImportError: If the ``[tracing]`` extra is not installed.
|
|
34
|
+
"""
|
|
35
|
+
if not HAS_OTEL_GRPC:
|
|
36
|
+
raise ImportError(_MISSING_MESSAGE)
|
|
37
|
+
GrpcAioInstrumentorServer().instrument(**kwargs)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def uninstrument_aio_server() -> None:
|
|
41
|
+
"""Remove OpenTelemetry instrumentation from the async gRPC server.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ImportError: If the ``[tracing]`` extra is not installed.
|
|
45
|
+
"""
|
|
46
|
+
if not HAS_OTEL_GRPC:
|
|
47
|
+
raise ImportError(_MISSING_MESSAGE)
|
|
48
|
+
GrpcAioInstrumentorServer().uninstrument()
|