struct-sdk 0.4.13__tar.gz → 0.4.14__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.
Files changed (26) hide show
  1. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/PKG-INFO +35 -1
  2. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/README.md +34 -0
  3. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/pyproject.toml +9 -2
  4. struct_sdk-0.4.14/src/struct_sdk/gcp.py +477 -0
  5. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/.gitignore +0 -0
  6. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/LICENSE +0 -0
  7. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/__init__.py +0 -0
  8. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/_auto.py +0 -0
  9. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/_distro.py +0 -0
  10. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/_genai_content.py +0 -0
  11. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/_operation_details.py +0 -0
  12. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/ag2.py +0 -0
  13. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/anthropic.py +0 -0
  14. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/autogen.py +0 -0
  15. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/claude_agent.py +0 -0
  16. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/core.py +0 -0
  17. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/crewai.py +0 -0
  18. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/google_genai.py +0 -0
  19. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/langchain.py +0 -0
  20. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/litellm.py +0 -0
  21. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/llamaindex.py +0 -0
  22. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/mcp.py +0 -0
  23. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/openai.py +0 -0
  24. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/openai_agents.py +0 -0
  25. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/pydantic_ai.py +0 -0
  26. {struct_sdk-0.4.13 → struct_sdk-0.4.14}/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.13
3
+ Version: 0.4.14
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
@@ -948,6 +948,40 @@ Temporal server metrics are a Prometheus endpoint; scrape them with an
948
948
  OpenTelemetry Collector and export OTLP/HTTP to your Struct ingest endpoint
949
949
  with the `x-struct-ingest-key` header.
950
950
 
951
+ ### Google Cloud clients
952
+
953
+ ```python
954
+ from struct_sdk import gcp, struct
955
+
956
+ struct.init(ingest_key="pk-...", service_name="monitor-worker")
957
+ gcp.instrument() # once at startup, before creating Google clients
958
+ ```
959
+
960
+ Released gRPC Google clients (Cloud Logging, Cloud Monitoring, Secret Manager,
961
+ Pub/Sub admin and the other generated libraries) emit no OpenTelemetry spans.
962
+ `gcp.instrument()` (no extra needed) gives each unary RPC attempt one CLIENT
963
+ span, `google.monitoring.v3.MetricService/ListTimeSeries`, with `rpc.service`,
964
+ `rpc.method`, `rpc.grpc.status_code` and `server.address`; a retried call shows
965
+ one span per attempt. Streaming RPCs are passed through untouched. Your
966
+ metadata keeps repeated keys; the trace headers of your configured
967
+ propagator replace any copies of its fields, and trace fields gRPC would
968
+ reject are dropped. A failure in Struct's tracing never affects the call. Spans go to the global tracer provider (Struct's, or yours
969
+ when Struct attaches to your OpenTelemetry setup, so your exporter gets them
970
+ too), or to `tracer_provider=` if you pass one. Channels already traced by
971
+ contrib's gRPC instrumentor keep its provider and behavior. `struct.init()` never calls it
972
+ for you. Clients created before the call, and transports you give your own
973
+ `channel`, are left alone. `OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=gcp` turns it
974
+ off and `OTEL_PYTHON_GRPC_EXCLUDED_SERVICES` excludes services; under a ddtrace
975
+ that patches `grpc` it steps aside.
976
+
977
+ Other Google libraries trace themselves: `google-cloud-bigquery` emits its own
978
+ `BigQuery.*` spans whenever `opentelemetry-instrumentation` is installed (its
979
+ `path` and `db.name` attributes carry project, dataset and table names, never
980
+ query text). `google-cloud-storage` needs `ENABLE_GCS_PYTHON_CLIENT_OTEL_TRACES=True`
981
+ set before it is imported, and the Pub/Sub publisher and subscriber need
982
+ `enable_open_telemetry_tracing=True` in their options. `google-api-python-client`
983
+ (discovery) calls get only the HTTP spans of an instrumented transport.
984
+
951
985
  ## Reliability
952
986
 
953
987
  The SDK is designed never to break your application. Instrumentation
@@ -832,6 +832,40 @@ Temporal server metrics are a Prometheus endpoint; scrape them with an
832
832
  OpenTelemetry Collector and export OTLP/HTTP to your Struct ingest endpoint
833
833
  with the `x-struct-ingest-key` header.
834
834
 
835
+ ### Google Cloud clients
836
+
837
+ ```python
838
+ from struct_sdk import gcp, struct
839
+
840
+ struct.init(ingest_key="pk-...", service_name="monitor-worker")
841
+ gcp.instrument() # once at startup, before creating Google clients
842
+ ```
843
+
844
+ Released gRPC Google clients (Cloud Logging, Cloud Monitoring, Secret Manager,
845
+ Pub/Sub admin and the other generated libraries) emit no OpenTelemetry spans.
846
+ `gcp.instrument()` (no extra needed) gives each unary RPC attempt one CLIENT
847
+ span, `google.monitoring.v3.MetricService/ListTimeSeries`, with `rpc.service`,
848
+ `rpc.method`, `rpc.grpc.status_code` and `server.address`; a retried call shows
849
+ one span per attempt. Streaming RPCs are passed through untouched. Your
850
+ metadata keeps repeated keys; the trace headers of your configured
851
+ propagator replace any copies of its fields, and trace fields gRPC would
852
+ reject are dropped. A failure in Struct's tracing never affects the call. Spans go to the global tracer provider (Struct's, or yours
853
+ when Struct attaches to your OpenTelemetry setup, so your exporter gets them
854
+ too), or to `tracer_provider=` if you pass one. Channels already traced by
855
+ contrib's gRPC instrumentor keep its provider and behavior. `struct.init()` never calls it
856
+ for you. Clients created before the call, and transports you give your own
857
+ `channel`, are left alone. `OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=gcp` turns it
858
+ off and `OTEL_PYTHON_GRPC_EXCLUDED_SERVICES` excludes services; under a ddtrace
859
+ that patches `grpc` it steps aside.
860
+
861
+ Other Google libraries trace themselves: `google-cloud-bigquery` emits its own
862
+ `BigQuery.*` spans whenever `opentelemetry-instrumentation` is installed (its
863
+ `path` and `db.name` attributes carry project, dataset and table names, never
864
+ query text). `google-cloud-storage` needs `ENABLE_GCS_PYTHON_CLIENT_OTEL_TRACES=True`
865
+ set before it is imported, and the Pub/Sub publisher and subscriber need
866
+ `enable_open_telemetry_tracing=True` in their options. `google-api-python-client`
867
+ (discovery) calls get only the HTTP spans of an instrumented transport.
868
+
835
869
  ## Reliability
836
870
 
837
871
  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.13"
7
+ version = "0.4.14"
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,13 @@ 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",
214
221
  ]
215
222
  # The openai<3 suites: `uv run --no-default-groups --group openai2 pytest
216
223
  # tests/test_litellm.py tests/test_llamaindex.py` (pnpm run test:openai2).
@@ -254,7 +261,7 @@ markers = [
254
261
 
255
262
  [tool.mypy]
256
263
  [[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.*"]
264
+ 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
265
  ignore_missing_imports = true
259
266
 
260
267
  [tool.hatch.build.targets.wheel]
@@ -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