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.
Files changed (26) hide show
  1. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/PKG-INFO +44 -3
  2. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/README.md +43 -2
  3. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/pyproject.toml +18 -2
  4. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/_auto.py +4 -2
  5. struct_sdk-0.4.15/src/struct_sdk/gcp.py +477 -0
  6. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/.gitignore +0 -0
  7. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/LICENSE +0 -0
  8. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/__init__.py +0 -0
  9. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/_distro.py +0 -0
  10. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/_genai_content.py +0 -0
  11. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/_operation_details.py +0 -0
  12. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/ag2.py +0 -0
  13. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/anthropic.py +0 -0
  14. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/autogen.py +0 -0
  15. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/claude_agent.py +0 -0
  16. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/core.py +0 -0
  17. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/crewai.py +0 -0
  18. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/google_genai.py +0 -0
  19. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/langchain.py +0 -0
  20. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/litellm.py +0 -0
  21. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/llamaindex.py +0 -0
  22. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/mcp.py +0 -0
  23. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/openai.py +0 -0
  24. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/openai_agents.py +0 -0
  25. {struct_sdk-0.4.13 → struct_sdk-0.4.15}/src/struct_sdk/pydantic_ai.py +0 -0
  26. {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.13
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`, to avoid duplicate spans and
898
- conflicting trace headers, and logs one INFO line listing them. Logs, process metrics, thread context, AI spans and libraries
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`, to avoid duplicate spans and
782
- conflicting trace headers, and logs one INFO line listing them. Logs, process metrics, thread context, AI spans and libraries
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.13"
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
- _TRANSPORT_BENEATH = {"urllib3": ("requests", "botocore")}
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