struct-sdk 0.4.13__tar.gz → 0.4.15__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/PKG-INFO +44 -3
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/README.md +43 -2
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/pyproject.toml +18 -2
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/_auto.py +4 -2
- struct_sdk-0.4.15/src/struct_sdk/gcp.py +477 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/.gitignore +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/LICENSE +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/__init__.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/_distro.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/_genai_content.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/_operation_details.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/ag2.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/anthropic.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/autogen.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/claude_agent.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/core.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/crewai.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/google_genai.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/langchain.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/litellm.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/llamaindex.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/mcp.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/openai.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/openai_agents.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/pydantic_ai.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/temporal.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: struct-sdk
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.15
|
|
4
4
|
Summary: Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry
|
|
5
5
|
Project-URL: Homepage, https://struct.ai
|
|
6
6
|
Project-URL: Documentation, https://struct.ai/docs
|
|
@@ -862,6 +862,12 @@ before `init()`, call the contrib instrumentor directly (for example
|
|
|
862
862
|
| Logs | Python `logging` records that reach the root logger, with trace context. The SDK's own and OpenTelemetry's records are excluded. With the `[structlog]` extra, structlog events that structlog writes itself (its default) are exported with their fields as attributes; events structlog hands to `logging` (`structlog.stdlib.LoggerFactory`) are exported once, through the `logging` bridge. Extras and structlog fields OpenTelemetry can't take as-is are exported as strings: dicts and mixed-type lists as JSON, everything else (UUIDs, datetimes, `None`, bytes) as its `str()`. Your other handlers still see the original values. With the `[exceptions]` extra, uncaught exceptions (main thread, other threads, asyncio) are exported as log records. If your app configures no logging, its warnings still print to stderr as they would without Struct. This is separate from `content_capture`, which only governs LLM content; disable `{"logging": {"enabled": False}}` and `{"structlog": {"enabled": False}}` (which also stops copies of your logs when Struct attaches to your OpenTelemetry setup), or redact attributes, if application logs can carry sensitive data. |
|
|
863
863
|
| Metrics | Instrumentor metrics (for example `http.server.duration`) and process metrics (`process.cpu.*`, `process.memory.*`, threads, file descriptors, GC). Counters and histograms use delta temporality. |
|
|
864
864
|
|
|
865
|
+
AWS calls through boto3/botocore or aiobotocore become one span per API
|
|
866
|
+
operation (for example `S3.PutObject`), retries included, and carry the X-Ray
|
|
867
|
+
header AWS services propagate. When Struct attaches to your existing
|
|
868
|
+
OpenTelemetry setup, it instruments nothing, so AWS operation spans reach
|
|
869
|
+
Struct only if your setup instruments botocore itself.
|
|
870
|
+
|
|
865
871
|
Each instrumentor activates only when its library is installed.
|
|
866
872
|
`instrumentation_options` disables or configures one by name; other keys are
|
|
867
873
|
passed to that instrumentor:
|
|
@@ -894,8 +900,9 @@ them.
|
|
|
894
900
|
### Datadog ddtrace
|
|
895
901
|
|
|
896
902
|
Under `ddtrace-run`, Struct leaves the libraries ddtrace patches to it, and
|
|
897
|
-
`urllib3` beneath `requests` or `botocore
|
|
898
|
-
|
|
903
|
+
`urllib3` beneath `requests` or `botocore` (and `aiobotocore` when ddtrace
|
|
904
|
+
patches `botocore`), to avoid duplicate spans and conflicting trace headers,
|
|
905
|
+
and logs one INFO line listing them. Logs, process metrics, thread context, AI spans and libraries
|
|
899
906
|
ddtrace doesn't patch still go to Struct. With `DD_TRACE_OTEL_ENABLED=true`,
|
|
900
907
|
ddtrace's OpenTelemetry provider isn't the OpenTelemetry SDK, so full-app
|
|
901
908
|
mode attaches nothing (an INFO line says so) and only AI spans reach Struct.
|
|
@@ -948,6 +955,40 @@ Temporal server metrics are a Prometheus endpoint; scrape them with an
|
|
|
948
955
|
OpenTelemetry Collector and export OTLP/HTTP to your Struct ingest endpoint
|
|
949
956
|
with the `x-struct-ingest-key` header.
|
|
950
957
|
|
|
958
|
+
### Google Cloud clients
|
|
959
|
+
|
|
960
|
+
```python
|
|
961
|
+
from struct_sdk import gcp, struct
|
|
962
|
+
|
|
963
|
+
struct.init(ingest_key="pk-...", service_name="monitor-worker")
|
|
964
|
+
gcp.instrument() # once at startup, before creating Google clients
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
Released gRPC Google clients (Cloud Logging, Cloud Monitoring, Secret Manager,
|
|
968
|
+
Pub/Sub admin and the other generated libraries) emit no OpenTelemetry spans.
|
|
969
|
+
`gcp.instrument()` (no extra needed) gives each unary RPC attempt one CLIENT
|
|
970
|
+
span, `google.monitoring.v3.MetricService/ListTimeSeries`, with `rpc.service`,
|
|
971
|
+
`rpc.method`, `rpc.grpc.status_code` and `server.address`; a retried call shows
|
|
972
|
+
one span per attempt. Streaming RPCs are passed through untouched. Your
|
|
973
|
+
metadata keeps repeated keys; the trace headers of your configured
|
|
974
|
+
propagator replace any copies of its fields, and trace fields gRPC would
|
|
975
|
+
reject are dropped. A failure in Struct's tracing never affects the call. Spans go to the global tracer provider (Struct's, or yours
|
|
976
|
+
when Struct attaches to your OpenTelemetry setup, so your exporter gets them
|
|
977
|
+
too), or to `tracer_provider=` if you pass one. Channels already traced by
|
|
978
|
+
contrib's gRPC instrumentor keep its provider and behavior. `struct.init()` never calls it
|
|
979
|
+
for you. Clients created before the call, and transports you give your own
|
|
980
|
+
`channel`, are left alone. `OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=gcp` turns it
|
|
981
|
+
off and `OTEL_PYTHON_GRPC_EXCLUDED_SERVICES` excludes services; under a ddtrace
|
|
982
|
+
that patches `grpc` it steps aside.
|
|
983
|
+
|
|
984
|
+
Other Google libraries trace themselves: `google-cloud-bigquery` emits its own
|
|
985
|
+
`BigQuery.*` spans whenever `opentelemetry-instrumentation` is installed (its
|
|
986
|
+
`path` and `db.name` attributes carry project, dataset and table names, never
|
|
987
|
+
query text). `google-cloud-storage` needs `ENABLE_GCS_PYTHON_CLIENT_OTEL_TRACES=True`
|
|
988
|
+
set before it is imported, and the Pub/Sub publisher and subscriber need
|
|
989
|
+
`enable_open_telemetry_tracing=True` in their options. `google-api-python-client`
|
|
990
|
+
(discovery) calls get only the HTTP spans of an instrumented transport.
|
|
991
|
+
|
|
951
992
|
## Reliability
|
|
952
993
|
|
|
953
994
|
The SDK is designed never to break your application. Instrumentation
|
|
@@ -746,6 +746,12 @@ before `init()`, call the contrib instrumentor directly (for example
|
|
|
746
746
|
| Logs | Python `logging` records that reach the root logger, with trace context. The SDK's own and OpenTelemetry's records are excluded. With the `[structlog]` extra, structlog events that structlog writes itself (its default) are exported with their fields as attributes; events structlog hands to `logging` (`structlog.stdlib.LoggerFactory`) are exported once, through the `logging` bridge. Extras and structlog fields OpenTelemetry can't take as-is are exported as strings: dicts and mixed-type lists as JSON, everything else (UUIDs, datetimes, `None`, bytes) as its `str()`. Your other handlers still see the original values. With the `[exceptions]` extra, uncaught exceptions (main thread, other threads, asyncio) are exported as log records. If your app configures no logging, its warnings still print to stderr as they would without Struct. This is separate from `content_capture`, which only governs LLM content; disable `{"logging": {"enabled": False}}` and `{"structlog": {"enabled": False}}` (which also stops copies of your logs when Struct attaches to your OpenTelemetry setup), or redact attributes, if application logs can carry sensitive data. |
|
|
747
747
|
| Metrics | Instrumentor metrics (for example `http.server.duration`) and process metrics (`process.cpu.*`, `process.memory.*`, threads, file descriptors, GC). Counters and histograms use delta temporality. |
|
|
748
748
|
|
|
749
|
+
AWS calls through boto3/botocore or aiobotocore become one span per API
|
|
750
|
+
operation (for example `S3.PutObject`), retries included, and carry the X-Ray
|
|
751
|
+
header AWS services propagate. When Struct attaches to your existing
|
|
752
|
+
OpenTelemetry setup, it instruments nothing, so AWS operation spans reach
|
|
753
|
+
Struct only if your setup instruments botocore itself.
|
|
754
|
+
|
|
749
755
|
Each instrumentor activates only when its library is installed.
|
|
750
756
|
`instrumentation_options` disables or configures one by name; other keys are
|
|
751
757
|
passed to that instrumentor:
|
|
@@ -778,8 +784,9 @@ them.
|
|
|
778
784
|
### Datadog ddtrace
|
|
779
785
|
|
|
780
786
|
Under `ddtrace-run`, Struct leaves the libraries ddtrace patches to it, and
|
|
781
|
-
`urllib3` beneath `requests` or `botocore
|
|
782
|
-
|
|
787
|
+
`urllib3` beneath `requests` or `botocore` (and `aiobotocore` when ddtrace
|
|
788
|
+
patches `botocore`), to avoid duplicate spans and conflicting trace headers,
|
|
789
|
+
and logs one INFO line listing them. Logs, process metrics, thread context, AI spans and libraries
|
|
783
790
|
ddtrace doesn't patch still go to Struct. With `DD_TRACE_OTEL_ENABLED=true`,
|
|
784
791
|
ddtrace's OpenTelemetry provider isn't the OpenTelemetry SDK, so full-app
|
|
785
792
|
mode attaches nothing (an INFO line says so) and only AI spans reach Struct.
|
|
@@ -832,6 +839,40 @@ Temporal server metrics are a Prometheus endpoint; scrape them with an
|
|
|
832
839
|
OpenTelemetry Collector and export OTLP/HTTP to your Struct ingest endpoint
|
|
833
840
|
with the `x-struct-ingest-key` header.
|
|
834
841
|
|
|
842
|
+
### Google Cloud clients
|
|
843
|
+
|
|
844
|
+
```python
|
|
845
|
+
from struct_sdk import gcp, struct
|
|
846
|
+
|
|
847
|
+
struct.init(ingest_key="pk-...", service_name="monitor-worker")
|
|
848
|
+
gcp.instrument() # once at startup, before creating Google clients
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
Released gRPC Google clients (Cloud Logging, Cloud Monitoring, Secret Manager,
|
|
852
|
+
Pub/Sub admin and the other generated libraries) emit no OpenTelemetry spans.
|
|
853
|
+
`gcp.instrument()` (no extra needed) gives each unary RPC attempt one CLIENT
|
|
854
|
+
span, `google.monitoring.v3.MetricService/ListTimeSeries`, with `rpc.service`,
|
|
855
|
+
`rpc.method`, `rpc.grpc.status_code` and `server.address`; a retried call shows
|
|
856
|
+
one span per attempt. Streaming RPCs are passed through untouched. Your
|
|
857
|
+
metadata keeps repeated keys; the trace headers of your configured
|
|
858
|
+
propagator replace any copies of its fields, and trace fields gRPC would
|
|
859
|
+
reject are dropped. A failure in Struct's tracing never affects the call. Spans go to the global tracer provider (Struct's, or yours
|
|
860
|
+
when Struct attaches to your OpenTelemetry setup, so your exporter gets them
|
|
861
|
+
too), or to `tracer_provider=` if you pass one. Channels already traced by
|
|
862
|
+
contrib's gRPC instrumentor keep its provider and behavior. `struct.init()` never calls it
|
|
863
|
+
for you. Clients created before the call, and transports you give your own
|
|
864
|
+
`channel`, are left alone. `OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=gcp` turns it
|
|
865
|
+
off and `OTEL_PYTHON_GRPC_EXCLUDED_SERVICES` excludes services; under a ddtrace
|
|
866
|
+
that patches `grpc` it steps aside.
|
|
867
|
+
|
|
868
|
+
Other Google libraries trace themselves: `google-cloud-bigquery` emits its own
|
|
869
|
+
`BigQuery.*` spans whenever `opentelemetry-instrumentation` is installed (its
|
|
870
|
+
`path` and `db.name` attributes carry project, dataset and table names, never
|
|
871
|
+
query text). `google-cloud-storage` needs `ENABLE_GCS_PYTHON_CLIENT_OTEL_TRACES=True`
|
|
872
|
+
set before it is imported, and the Pub/Sub publisher and subscriber need
|
|
873
|
+
`enable_open_telemetry_tracing=True` in their options. `google-api-python-client`
|
|
874
|
+
(discovery) calls get only the HTTP spans of an instrumented transport.
|
|
875
|
+
|
|
835
876
|
## Reliability
|
|
836
877
|
|
|
837
878
|
The SDK is designed never to break your application. Instrumentation
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "struct-sdk"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.15"
|
|
8
8
|
description = "Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -211,6 +211,22 @@ dev = [
|
|
|
211
211
|
# runs over a transport-mocked OpenAI client.
|
|
212
212
|
"autogen-agentchat>=0.7.0",
|
|
213
213
|
"autogen-ext[openai]>=0.7.0",
|
|
214
|
+
# Google Cloud suite (tests/test_gcp.py): real GAPIC gRPC and BigQuery
|
|
215
|
+
# REST clients against in-process fake services, next to contrib's gRPC
|
|
216
|
+
# instrumentor that gcp.instrument() defers to.
|
|
217
|
+
"google-cloud-monitoring>=2.29.1",
|
|
218
|
+
"google-cloud-bigquery>=3.30.0",
|
|
219
|
+
"opentelemetry-instrumentation-grpc",
|
|
220
|
+
"opentelemetry-propagator-b3",
|
|
221
|
+
# AWS client-operation suite (tests/test_aws_client_operations.py): real
|
|
222
|
+
# boto3 and aiobotocore clients, contrib's botocore instrumentor and the
|
|
223
|
+
# transport instrumentors beneath them (urllib3, aiohttp-client), against
|
|
224
|
+
# a local endpoint.
|
|
225
|
+
"boto3",
|
|
226
|
+
"aiobotocore",
|
|
227
|
+
"opentelemetry-instrumentation-botocore",
|
|
228
|
+
"opentelemetry-instrumentation-urllib3",
|
|
229
|
+
"opentelemetry-instrumentation-aiohttp-client",
|
|
214
230
|
]
|
|
215
231
|
# The openai<3 suites: `uv run --no-default-groups --group openai2 pytest
|
|
216
232
|
# tests/test_litellm.py tests/test_llamaindex.py` (pnpm run test:openai2).
|
|
@@ -254,7 +270,7 @@ markers = [
|
|
|
254
270
|
|
|
255
271
|
[tool.mypy]
|
|
256
272
|
[[tool.mypy.overrides]]
|
|
257
|
-
module = ["agents", "agents.*", "anthropic", "anthropic.*", "pydantic_ai", "pydantic_ai.*", "claude_agent_sdk", "claude_agent_sdk.*", "langchain_core", "langchain_core.*", "langchain", "langchain.*", "langgraph", "langgraph.*", "openai", "openai.*", "opentelemetry.instrumentation.*", "azure.core.*", "temporalio", "temporalio.*", "requests", "requests.*", "opentelemetry.resource.*", "ddtrace", "ddtrace.*", "wrapt", "wrapt.*", "mcp", "mcp.*", "google.genai", "google.genai.*", "google.adk", "google.adk.*", "litellm", "litellm.*", "llama_index", "llama_index.*", "crewai", "crewai.*", "autogen_core", "autogen_core.*", "autogen_ext", "autogen_ext.*", "autogen_agentchat", "autogen_agentchat.*", "autogen", "autogen.*", "ag2", "ag2.*"]
|
|
273
|
+
module = ["agents", "agents.*", "anthropic", "anthropic.*", "pydantic_ai", "pydantic_ai.*", "claude_agent_sdk", "claude_agent_sdk.*", "langchain_core", "langchain_core.*", "langchain", "langchain.*", "langgraph", "langgraph.*", "openai", "openai.*", "opentelemetry.instrumentation.*", "azure.core.*", "temporalio", "temporalio.*", "requests", "requests.*", "opentelemetry.resource.*", "ddtrace", "ddtrace.*", "wrapt", "wrapt.*", "mcp", "mcp.*", "google.genai", "google.genai.*", "google.adk", "google.adk.*", "litellm", "litellm.*", "llama_index", "llama_index.*", "crewai", "crewai.*", "autogen_core", "autogen_core.*", "autogen_ext", "autogen_ext.*", "autogen_agentchat", "autogen_agentchat.*", "autogen", "autogen.*", "ag2", "ag2.*", "grpc", "grpc.*"]
|
|
258
274
|
ignore_missing_imports = true
|
|
259
275
|
|
|
260
276
|
[tool.hatch.build.targets.wheel]
|
|
@@ -132,8 +132,10 @@ _DDTRACE_INTEGRATIONS = {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
# Transports beneath clients ddtrace patches: instrumenting one would overwrite
|
|
135
|
-
# the traceparent ddtrace wrote on the same request.
|
|
136
|
-
|
|
135
|
+
# the traceparent ddtrace wrote on the same request. contrib's aiobotocore
|
|
136
|
+
# instrumentor also wraps botocore's Endpoint.prepare_request, so it would write
|
|
137
|
+
# Struct's X-Ray header on every botocore request ddtrace traces.
|
|
138
|
+
_TRANSPORT_BENEATH = {"urllib3": ("requests", "botocore"), "aiobotocore": ("botocore",)}
|
|
137
139
|
|
|
138
140
|
# Non-entry-point options handled here rather than by a contrib instrumentor.
|
|
139
141
|
LOGGING = "logging"
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Google Cloud client operations traced with OpenTelemetry.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from struct_sdk import gcp, struct
|
|
6
|
+
|
|
7
|
+
struct.init(ingest_key="pk-...", service_name="monitor-worker")
|
|
8
|
+
gcp.instrument() # once at startup, before the app creates Google clients
|
|
9
|
+
|
|
10
|
+
Google's generated gRPC clients (Cloud Logging, Cloud Monitoring, Secret
|
|
11
|
+
Manager, Pub/Sub's admin clients and the rest of the GAPIC libraries) emit no
|
|
12
|
+
OpenTelemetry spans of their own in their released versions. ``instrument()``
|
|
13
|
+
gives every unary RPC attempt on a channel Google's
|
|
14
|
+
``grpc_helpers.create_channel`` builds from then on one CLIENT span, the layer
|
|
15
|
+
``google-api-core[tracing]`` calls an attempt span. Streaming RPCs pass
|
|
16
|
+
through untouched. Spans go to the global tracer provider unless one is
|
|
17
|
+
passed: Struct's in owned mode, the host's in attach mode, where Struct's
|
|
18
|
+
attached exporter copies them.
|
|
19
|
+
|
|
20
|
+
google-cloud-bigquery traces its own operations whenever
|
|
21
|
+
``opentelemetry-instrumentation`` is importable and needs nothing here.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import collections
|
|
28
|
+
import functools
|
|
29
|
+
import importlib
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
import threading
|
|
35
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
36
|
+
|
|
37
|
+
from struct_sdk import _auto
|
|
38
|
+
from struct_sdk.core import _SDK_VERSION, _contain_logged
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from opentelemetry.trace import Span, Tracer, TracerProvider
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("struct_sdk.gcp")
|
|
44
|
+
|
|
45
|
+
# This integration's name in OTEL_PYTHON_DISABLED_INSTRUMENTATIONS.
|
|
46
|
+
NAME = "gcp"
|
|
47
|
+
# Contrib's gRPC exclusion list, read the same way.
|
|
48
|
+
OTEL_PYTHON_GRPC_EXCLUDED_SERVICES = "OTEL_PYTHON_GRPC_EXCLUDED_SERVICES"
|
|
49
|
+
|
|
50
|
+
_CHANNEL_MODULES = ("google.api_core.grpc_helpers", "google.api_core.grpc_helpers_async")
|
|
51
|
+
|
|
52
|
+
_lock = threading.Lock()
|
|
53
|
+
# The create_channel wrapper Struct installed, per module, compared by
|
|
54
|
+
# identity: a host decorator built with functools.wraps copies its attributes.
|
|
55
|
+
_installed: dict[str, Callable[..., Any]] = {}
|
|
56
|
+
|
|
57
|
+
# What gRPC accepts as a metadata key: lower-case token characters.
|
|
58
|
+
_METADATA_KEY = re.compile(r"[0-9a-z_.\-]+\Z")
|
|
59
|
+
# Every header B3 propagators read, multi-header and single-header alike: a
|
|
60
|
+
# propagator's ``fields`` names only those it writes.
|
|
61
|
+
_B3_HEADERS = frozenset(
|
|
62
|
+
{"b3", "x-b3-traceid", "x-b3-spanid", "x-b3-parentspanid", "x-b3-sampled", "x-b3-flags"}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def instrument(*, tracer_provider: Optional["TracerProvider"] = None) -> bool:
|
|
67
|
+
"""Trace the unary RPCs on gRPC channels Google Cloud clients create from now on.
|
|
68
|
+
|
|
69
|
+
Returns True when tracing is on, including after an earlier call, whose
|
|
70
|
+
``tracer_provider`` stays. Returns False, without raising, when
|
|
71
|
+
OTEL_PYTHON_DISABLED_INSTRUMENTATIONS turns ``gcp`` off, when Datadog
|
|
72
|
+
ddtrace instruments grpc, or when google-api-core's gRPC support is
|
|
73
|
+
missing. Clients built before the call, and transports given their own
|
|
74
|
+
``channel``, are left as they are.
|
|
75
|
+
"""
|
|
76
|
+
on = False
|
|
77
|
+
|
|
78
|
+
def setup() -> None:
|
|
79
|
+
nonlocal on
|
|
80
|
+
on = _instrument(tracer_provider)
|
|
81
|
+
|
|
82
|
+
_contain_logged(setup, site="gcp.instrument")
|
|
83
|
+
return on
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def uninstrument() -> None:
|
|
87
|
+
"""Stop tracing new Google Cloud channels; existing ones keep tracing."""
|
|
88
|
+
|
|
89
|
+
def teardown() -> None:
|
|
90
|
+
with _lock:
|
|
91
|
+
for module_name, wrapper in list(_installed.items()):
|
|
92
|
+
wrapper.__struct_active__ = False # type: ignore[attr-defined]
|
|
93
|
+
module = sys.modules.get(module_name)
|
|
94
|
+
if getattr(module, "create_channel", None) is wrapper:
|
|
95
|
+
setattr(module, "create_channel", wrapper.__struct_original__) # type: ignore[attr-defined]
|
|
96
|
+
del _installed[module_name]
|
|
97
|
+
# Otherwise the host wrapped Struct's layer: it stays, passing through.
|
|
98
|
+
|
|
99
|
+
_contain_logged(teardown, site="gcp.uninstrument")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _instrument(tracer_provider: Optional["TracerProvider"]) -> bool:
|
|
103
|
+
if _disabled():
|
|
104
|
+
logger.debug(
|
|
105
|
+
"Skipping %s instrumentation: turned off by %s",
|
|
106
|
+
NAME,
|
|
107
|
+
_auto.OTEL_PYTHON_DISABLED_INSTRUMENTATIONS,
|
|
108
|
+
)
|
|
109
|
+
return False
|
|
110
|
+
try:
|
|
111
|
+
grpc_helpers = importlib.import_module("google.api_core.grpc_helpers")
|
|
112
|
+
except ImportError:
|
|
113
|
+
logger.debug("Skipping %s instrumentation: google-api-core gRPC support is not installed", NAME)
|
|
114
|
+
return False
|
|
115
|
+
if _ddtrace_owns_grpc():
|
|
116
|
+
logger.info(
|
|
117
|
+
"Struct left Google Cloud gRPC clients to Datadog ddtrace, which instruments grpc, "
|
|
118
|
+
"to avoid duplicate spans and conflicting trace headers."
|
|
119
|
+
)
|
|
120
|
+
return False
|
|
121
|
+
with _lock:
|
|
122
|
+
modules = [(grpc_helpers, False)]
|
|
123
|
+
try:
|
|
124
|
+
modules.append((importlib.import_module("google.api_core.grpc_helpers_async"), True))
|
|
125
|
+
except ImportError:
|
|
126
|
+
pass
|
|
127
|
+
installed_now = False
|
|
128
|
+
attempts: Optional[_Attempts] = None
|
|
129
|
+
for module, aio in modules:
|
|
130
|
+
wrapper = _installed.get(module.__name__)
|
|
131
|
+
if wrapper is not None and _reachable(module.create_channel, wrapper):
|
|
132
|
+
# Still in place, possibly beneath a host wrapper: turn it back on.
|
|
133
|
+
wrapper.__struct_active__ = True # type: ignore[attr-defined]
|
|
134
|
+
continue
|
|
135
|
+
if wrapper is not None:
|
|
136
|
+
# It may still run inside a host wrapper that hides __wrapped__.
|
|
137
|
+
wrapper.__struct_active__ = False # type: ignore[attr-defined]
|
|
138
|
+
if attempts is None:
|
|
139
|
+
attempts = _Attempts(tracer_provider, _excluded_services())
|
|
140
|
+
wrapper = _tracing(module.create_channel, attempts, aio=aio)
|
|
141
|
+
setattr(module, "create_channel", wrapper)
|
|
142
|
+
_installed[module.__name__] = wrapper
|
|
143
|
+
installed_now = True
|
|
144
|
+
if installed_now:
|
|
145
|
+
logger.info("Auto-instrumented: %s", NAME)
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _reachable(current: Any, wrapper: Callable[..., Any]) -> bool:
|
|
150
|
+
"""Whether ``current`` is Struct's wrapper or wraps it (functools.wraps)."""
|
|
151
|
+
for _ in range(16):
|
|
152
|
+
if current is wrapper:
|
|
153
|
+
return True
|
|
154
|
+
current = getattr(current, "__wrapped__", None)
|
|
155
|
+
if current is None:
|
|
156
|
+
return False
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _disabled() -> bool:
|
|
161
|
+
names = {n.strip() for n in os.environ.get(_auto.OTEL_PYTHON_DISABLED_INSTRUMENTATIONS, "").split(",")}
|
|
162
|
+
return NAME in names or "*" in names
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _excluded_services() -> frozenset[str]:
|
|
166
|
+
services = (s.strip() for s in os.environ.get(OTEL_PYTHON_GRPC_EXCLUDED_SERVICES, "").split(","))
|
|
167
|
+
return frozenset(s for s in services if s)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _guarded(fn: Callable[[], Any], *, site: str, default: Any = None) -> Any:
|
|
171
|
+
result = default
|
|
172
|
+
|
|
173
|
+
def run() -> None:
|
|
174
|
+
nonlocal result
|
|
175
|
+
result = fn()
|
|
176
|
+
|
|
177
|
+
_contain_logged(run, site=site)
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _tracing(original: Callable[..., Any], attempts: "_Attempts", *, aio: bool) -> Callable[..., Any]:
|
|
182
|
+
"""A ``create_channel`` that adds Struct's interceptor to Google's channel."""
|
|
183
|
+
|
|
184
|
+
@functools.wraps(original)
|
|
185
|
+
def create_channel(*args: Any, **kwargs: Any) -> Any:
|
|
186
|
+
if not create_channel.__struct_active__ or _guarded( # type: ignore[attr-defined]
|
|
187
|
+
_ddtrace_owns_grpc, site="gcp.ddtrace", default=False
|
|
188
|
+
):
|
|
189
|
+
return original(*args, **kwargs)
|
|
190
|
+
target = args[0] if args else kwargs.get("target")
|
|
191
|
+
if aio:
|
|
192
|
+
# grpc.aio channels take their interceptors at construction.
|
|
193
|
+
interceptor = _guarded(lambda: attempts.aio_interceptor(target), site="gcp.aio_channel")
|
|
194
|
+
if interceptor is not None:
|
|
195
|
+
kwargs = {**kwargs, "interceptors": [interceptor, *(kwargs.get("interceptors") or ())]}
|
|
196
|
+
return original(*args, **kwargs)
|
|
197
|
+
channel = original(*args, **kwargs)
|
|
198
|
+
return _guarded(lambda: attempts.intercept(channel, target), site="gcp.channel", default=channel)
|
|
199
|
+
|
|
200
|
+
create_channel.__struct_original__ = original # type: ignore[attr-defined]
|
|
201
|
+
create_channel.__struct_active__ = True # type: ignore[attr-defined]
|
|
202
|
+
return create_channel
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _ddtrace_owns_grpc() -> bool:
|
|
206
|
+
"""ddtrace patched grpc, possibly after instrument(): channels created from
|
|
207
|
+
then on are its own."""
|
|
208
|
+
return _auto._left_to_ddtrace("grpc_client", _auto._ddtrace_instrumented())
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _contrib_instruments(class_name: str) -> bool:
|
|
212
|
+
"""Whether contrib's own gRPC client instrumentor (owned mode's
|
|
213
|
+
``grpc_client`` and ``grpc_aio_client``, or the host's) already traces
|
|
214
|
+
every new channel. Checked on the shared instance, which constructing one
|
|
215
|
+
would reset; a never-imported contrib cannot be on."""
|
|
216
|
+
contrib = sys.modules.get("opentelemetry.instrumentation.grpc")
|
|
217
|
+
instrumentor_class = getattr(contrib, class_name, None)
|
|
218
|
+
if instrumentor_class is None:
|
|
219
|
+
return False
|
|
220
|
+
return bool(instrumentor_class.__new__(instrumentor_class).is_instrumented_by_opentelemetry)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _server(target: Any) -> dict[str, Any]:
|
|
224
|
+
if not isinstance(target, str) or not target:
|
|
225
|
+
return {}
|
|
226
|
+
host, separator, port = target.rpartition(":")
|
|
227
|
+
if not separator or not port.isdigit() or host.endswith(":"):
|
|
228
|
+
return {"server.address": target.strip("[]")}
|
|
229
|
+
return {"server.address": host.strip("[]"), "server.port": int(port)}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class _Attempts:
|
|
233
|
+
"""Starts and ends one CLIENT span per unary RPC attempt.
|
|
234
|
+
|
|
235
|
+
Every telemetry step is contained: the RPC's continuation runs exactly
|
|
236
|
+
once, with the app's metadata (repeated keys included) plus the host
|
|
237
|
+
propagator's headers. The span is never made the current span, so no
|
|
238
|
+
context is attached across the call.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def __init__(self, tracer_provider: Optional["TracerProvider"], excluded: frozenset[str]) -> None:
|
|
242
|
+
from opentelemetry import trace
|
|
243
|
+
|
|
244
|
+
self._tracer: "Tracer" = trace.get_tracer("struct_sdk.gcp", _SDK_VERSION, tracer_provider)
|
|
245
|
+
self._excluded = excluded
|
|
246
|
+
|
|
247
|
+
def intercept(self, channel: Any, target: Any) -> Any:
|
|
248
|
+
if _contrib_instruments("GrpcInstrumentorClient"):
|
|
249
|
+
return channel
|
|
250
|
+
import grpc
|
|
251
|
+
|
|
252
|
+
return grpc.intercept_channel(channel, _interceptor_classes()[0](self, _server(target)))
|
|
253
|
+
|
|
254
|
+
def aio_interceptor(self, target: Any) -> Any:
|
|
255
|
+
if _contrib_instruments("GrpcAioInstrumentorClient"):
|
|
256
|
+
return None
|
|
257
|
+
return _interceptor_classes()[1](self, _server(target))
|
|
258
|
+
|
|
259
|
+
def start(self, details: Any, server: dict[str, Any], *, aio: bool) -> Optional["_Attempt"]:
|
|
260
|
+
from opentelemetry import context, trace
|
|
261
|
+
from opentelemetry.context import _SUPPRESS_INSTRUMENTATION_KEY
|
|
262
|
+
from opentelemetry.trace import SpanKind
|
|
263
|
+
|
|
264
|
+
if context.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
|
|
265
|
+
return None
|
|
266
|
+
method = details.method.decode() if isinstance(details.method, bytes) else details.method
|
|
267
|
+
service, _, name = (method or "").lstrip("/").rpartition("/")
|
|
268
|
+
if not service or service in self._excluded:
|
|
269
|
+
return None
|
|
270
|
+
span = self._tracer.start_span(
|
|
271
|
+
f"{service}/{name}",
|
|
272
|
+
kind=SpanKind.CLIENT,
|
|
273
|
+
attributes={"rpc.system": "grpc", "rpc.service": service, "rpc.method": name, **server},
|
|
274
|
+
)
|
|
275
|
+
metadata = list(details.metadata or ())
|
|
276
|
+
|
|
277
|
+
def propagate() -> None:
|
|
278
|
+
from opentelemetry.propagate import get_global_textmap, inject
|
|
279
|
+
|
|
280
|
+
carrier: dict[str, str] = {}
|
|
281
|
+
inject(carrier, context=trace.set_span_in_context(span))
|
|
282
|
+
headers = _grpc_headers(carrier)
|
|
283
|
+
# Every field the configured propagator owns describes this
|
|
284
|
+
# attempt, written or not: an app-supplied copy (a caller's own
|
|
285
|
+
# parent context, a leftover tracestate) would be extracted instead.
|
|
286
|
+
owned = _propagation_fields(get_global_textmap()) | headers.keys()
|
|
287
|
+
metadata[:] = [item for item in metadata if str(item[0]).lower() not in owned]
|
|
288
|
+
metadata.extend(headers.items())
|
|
289
|
+
|
|
290
|
+
_contain_logged(propagate, site="gcp.propagate")
|
|
291
|
+
attempt = _Attempt(span, details)
|
|
292
|
+
_contain_logged(lambda: attempt.send_with(metadata, aio=aio), site="gcp.metadata")
|
|
293
|
+
return attempt
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class _Attempt:
|
|
297
|
+
__slots__ = ("span", "details", "_ended")
|
|
298
|
+
|
|
299
|
+
def __init__(self, span: "Span", details: Any) -> None:
|
|
300
|
+
self.span = span
|
|
301
|
+
self.details = details
|
|
302
|
+
self._ended = False
|
|
303
|
+
|
|
304
|
+
def send_with(self, metadata: list[Any], *, aio: bool) -> None:
|
|
305
|
+
details = self.details
|
|
306
|
+
if aio:
|
|
307
|
+
from grpc import aio as grpc_aio
|
|
308
|
+
|
|
309
|
+
self.details = grpc_aio.ClientCallDetails(
|
|
310
|
+
details.method,
|
|
311
|
+
details.timeout,
|
|
312
|
+
grpc_aio.Metadata(*metadata),
|
|
313
|
+
details.credentials,
|
|
314
|
+
details.wait_for_ready,
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
self.details = _interceptor_classes()[2](
|
|
318
|
+
details.method,
|
|
319
|
+
details.timeout,
|
|
320
|
+
metadata,
|
|
321
|
+
details.credentials,
|
|
322
|
+
getattr(details, "wait_for_ready", None),
|
|
323
|
+
getattr(details, "compression", None),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def end(self, code: Any) -> None:
|
|
327
|
+
if self._ended:
|
|
328
|
+
return
|
|
329
|
+
self._ended = True
|
|
330
|
+
import grpc
|
|
331
|
+
from opentelemetry.trace import Status, StatusCode
|
|
332
|
+
|
|
333
|
+
code = code if isinstance(code, grpc.StatusCode) else grpc.StatusCode.UNKNOWN
|
|
334
|
+
_contain_logged(lambda: self.span.set_attribute("rpc.grpc.status_code", code.value[0]), site="gcp.status")
|
|
335
|
+
if code is not grpc.StatusCode.OK:
|
|
336
|
+
_contain_logged(lambda: self.span.set_status(Status(StatusCode.ERROR, code.name)), site="gcp.status")
|
|
337
|
+
self.span.end()
|
|
338
|
+
|
|
339
|
+
def end_when_done(self, outcome: Any) -> None:
|
|
340
|
+
def done(future: Any) -> None:
|
|
341
|
+
code = _guarded(lambda: future.code(), site="gcp.code")
|
|
342
|
+
_contain_logged(lambda: self.end(code), site="gcp.end")
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
outcome.add_done_callback(done)
|
|
346
|
+
except Exception:
|
|
347
|
+
done(outcome)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _propagation_fields(propagator: Any) -> set[str]:
|
|
351
|
+
fields = {str(field).lower() for field in (getattr(propagator, "fields", None) or ())}
|
|
352
|
+
if fields & _B3_HEADERS:
|
|
353
|
+
fields |= _B3_HEADERS
|
|
354
|
+
return fields
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _grpc_headers(carrier: dict[Any, Any]) -> dict[str, str]:
|
|
358
|
+
"""The propagator's fields gRPC accepts as metadata. gRPC fails the whole
|
|
359
|
+
call with INTERNAL on an upper-case or otherwise invalid key, a reserved
|
|
360
|
+
``grpc-`` key or a non-printable value, so those fields are dropped."""
|
|
361
|
+
headers = {}
|
|
362
|
+
for key, value in carrier.items():
|
|
363
|
+
key = str(key).lower()
|
|
364
|
+
if not _METADATA_KEY.match(key) or key.startswith("grpc-") or key.endswith("-bin"):
|
|
365
|
+
continue
|
|
366
|
+
if not isinstance(value, str) or not (value.isascii() and value.isprintable()):
|
|
367
|
+
continue
|
|
368
|
+
headers[key] = value
|
|
369
|
+
return headers
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _code_of(error: BaseException) -> Any:
|
|
373
|
+
if isinstance(error, asyncio.CancelledError):
|
|
374
|
+
import grpc
|
|
375
|
+
|
|
376
|
+
return grpc.StatusCode.CANCELLED
|
|
377
|
+
code = _guarded(lambda: getattr(error, "code", None), site="gcp.code")
|
|
378
|
+
return _guarded(code, site="gcp.code") if callable(code) else None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# Tasks reading a finished grpc.aio call's status (its code() is a coroutine).
|
|
382
|
+
_finishing: set["asyncio.Task[None]"] = set()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _end_when_aio_done(call: Any, attempt: _Attempt) -> None:
|
|
386
|
+
loop = asyncio.get_running_loop()
|
|
387
|
+
|
|
388
|
+
async def finish(done: Any) -> None:
|
|
389
|
+
code: Any = None
|
|
390
|
+
try:
|
|
391
|
+
code = await done.code()
|
|
392
|
+
except BaseException as error: # noqa: BLE001 — the status read only feeds the span
|
|
393
|
+
code = _code_of(error)
|
|
394
|
+
finally:
|
|
395
|
+
# Not left to a done callback: a loop closed right after this
|
|
396
|
+
# task finishes never runs one.
|
|
397
|
+
_finishing.discard(asyncio.current_task()) # type: ignore[arg-type]
|
|
398
|
+
_contain_logged(lambda: attempt.end(code), site="gcp.end")
|
|
399
|
+
|
|
400
|
+
def done(finished: Any) -> None:
|
|
401
|
+
def schedule() -> None:
|
|
402
|
+
task = loop.create_task(finish(finished))
|
|
403
|
+
_finishing.add(task)
|
|
404
|
+
task.add_done_callback(_finishing.discard)
|
|
405
|
+
|
|
406
|
+
_contain_logged(schedule, site="gcp.end")
|
|
407
|
+
|
|
408
|
+
# A host interceptor that awaited the call hands back grpc's finished
|
|
409
|
+
# response wrapper, which takes no callbacks.
|
|
410
|
+
try:
|
|
411
|
+
if call.done():
|
|
412
|
+
done(call)
|
|
413
|
+
else:
|
|
414
|
+
call.add_done_callback(done)
|
|
415
|
+
except Exception:
|
|
416
|
+
# The call cannot be observed: end the attempt now rather than never.
|
|
417
|
+
_contain_logged(lambda: attempt.end(None), site="gcp.end")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@functools.lru_cache(maxsize=None)
|
|
421
|
+
def _interceptor_classes() -> tuple[type, type, type]:
|
|
422
|
+
# Defined on first use: grpc is an optional dependency of this module.
|
|
423
|
+
import grpc
|
|
424
|
+
from grpc import aio
|
|
425
|
+
|
|
426
|
+
class UnaryUnaryAttempts(grpc.UnaryUnaryClientInterceptor): # type: ignore[misc]
|
|
427
|
+
def __init__(self, attempts: _Attempts, server: dict[str, Any]) -> None:
|
|
428
|
+
self._attempts = attempts
|
|
429
|
+
self._server = server
|
|
430
|
+
|
|
431
|
+
def intercept_unary_unary(self, continuation: Any, client_call_details: Any, request: Any) -> Any:
|
|
432
|
+
attempt = _guarded(
|
|
433
|
+
lambda: self._attempts.start(client_call_details, self._server, aio=False), site="gcp.start"
|
|
434
|
+
)
|
|
435
|
+
if attempt is None:
|
|
436
|
+
return continuation(client_call_details, request)
|
|
437
|
+
try:
|
|
438
|
+
outcome = continuation(attempt.details, request)
|
|
439
|
+
except BaseException as error:
|
|
440
|
+
failed = _code_of(error)
|
|
441
|
+
_contain_logged(lambda: attempt.end(failed), site="gcp.end")
|
|
442
|
+
raise
|
|
443
|
+
_contain_logged(lambda: attempt.end_when_done(outcome), site="gcp.end")
|
|
444
|
+
return outcome
|
|
445
|
+
|
|
446
|
+
class AioUnaryUnaryAttempts(aio.UnaryUnaryClientInterceptor): # type: ignore[misc]
|
|
447
|
+
def __init__(self, attempts: _Attempts, server: dict[str, Any]) -> None:
|
|
448
|
+
self._attempts = attempts
|
|
449
|
+
self._server = server
|
|
450
|
+
|
|
451
|
+
async def intercept_unary_unary(self, continuation: Any, client_call_details: Any, request: Any) -> Any:
|
|
452
|
+
attempt = _guarded(
|
|
453
|
+
lambda: self._attempts.start(client_call_details, self._server, aio=True), site="gcp.start"
|
|
454
|
+
)
|
|
455
|
+
if attempt is None:
|
|
456
|
+
return await continuation(client_call_details, request)
|
|
457
|
+
try:
|
|
458
|
+
call = await continuation(attempt.details, request)
|
|
459
|
+
except BaseException as error:
|
|
460
|
+
failed = _code_of(error)
|
|
461
|
+
_contain_logged(lambda: attempt.end(failed), site="gcp.end")
|
|
462
|
+
raise
|
|
463
|
+
# Returned at once, as without Struct, so waiting on, timing out
|
|
464
|
+
# or cancelling the call behaves as grpc does; the span ends when
|
|
465
|
+
# the call does.
|
|
466
|
+
_contain_logged(lambda: _end_when_aio_done(call, attempt), site="gcp.end")
|
|
467
|
+
return call
|
|
468
|
+
|
|
469
|
+
class CallDetails(
|
|
470
|
+
collections.namedtuple(
|
|
471
|
+
"CallDetails", ("method", "timeout", "metadata", "credentials", "wait_for_ready", "compression")
|
|
472
|
+
),
|
|
473
|
+
grpc.ClientCallDetails,
|
|
474
|
+
):
|
|
475
|
+
pass
|
|
476
|
+
|
|
477
|
+
return UnaryUnaryAttempts, AioUnaryUnaryAttempts, CallDetails
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|