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.
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/PKG-INFO +35 -1
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/README.md +34 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/pyproject.toml +9 -2
- struct_sdk-0.4.14/src/struct_sdk/gcp.py +477 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/.gitignore +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/LICENSE +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/__init__.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/_auto.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/_distro.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/_genai_content.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/_operation_details.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/ag2.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/anthropic.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/autogen.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/claude_agent.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/core.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/crewai.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/google_genai.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/langchain.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/litellm.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/llamaindex.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/mcp.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/openai.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/openai_agents.py +0 -0
- {struct_sdk-0.4.13 → struct_sdk-0.4.14}/src/struct_sdk/pydantic_ai.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|