From 69ce91bc9aa44183313b9d250f6084f00c0c86c2 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 14 Oct 2026 13:22:33 -0400 Subject: [PATCH 1/2] feat(secretmanager): add OpenTelemetry distributed tracing samples Add standalone snippets and unit test suites demonstrating native OpenTelemetry distributed tracing for SecretManagerServiceClient and SecretManagerServiceAsyncClient: - Basic quickstart snippet using ConsoleSpanExporter and environment variable activation - Google Cloud Trace exporter sample using CloudTraceSpanExporter and BatchSpanProcessor - Programmatic configuration sample using ClientOptions(tracer_provider=...) - Asynchronous client sample using SecretManagerServiceAsyncClient - Mocked unit test suites with conftest fixture for isolated test execution --- secretmanager/snippets/conftest.py | 27 +++ secretmanager/snippets/requirements-test.txt | 3 +- secretmanager/snippets/requirements.txt | 3 + secretmanager/snippets/secretmanager_trace.py | 84 ++++++++++ .../snippets/secretmanager_trace_async.py | 87 ++++++++++ .../secretmanager_trace_async_test.py | 158 ++++++++++++++++++ .../secretmanager_trace_client_options.py | 81 +++++++++ ...secretmanager_trace_client_options_test.py | 110 ++++++++++++ .../secretmanager_trace_cloud_trace.py | 86 ++++++++++ .../secretmanager_trace_cloud_trace_test.py | 117 +++++++++++++ .../snippets/secretmanager_trace_test.py | 148 ++++++++++++++++ 11 files changed, 903 insertions(+), 1 deletion(-) create mode 100644 secretmanager/snippets/conftest.py create mode 100644 secretmanager/snippets/secretmanager_trace.py create mode 100644 secretmanager/snippets/secretmanager_trace_async.py create mode 100644 secretmanager/snippets/secretmanager_trace_async_test.py create mode 100644 secretmanager/snippets/secretmanager_trace_client_options.py create mode 100644 secretmanager/snippets/secretmanager_trace_client_options_test.py create mode 100644 secretmanager/snippets/secretmanager_trace_cloud_trace.py create mode 100644 secretmanager/snippets/secretmanager_trace_cloud_trace_test.py create mode 100644 secretmanager/snippets/secretmanager_trace_test.py diff --git a/secretmanager/snippets/conftest.py b/secretmanager/snippets/conftest.py new file mode 100644 index 00000000000..c1ad6634ad2 --- /dev/null +++ b/secretmanager/snippets/conftest.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared test fixtures for Secret Manager tracing snippets.""" + +import pytest +from opentelemetry import trace + + +@pytest.fixture(autouse=True) +def reset_otel_global_tracer_provider(): + """Resets OpenTelemetry's global TracerProvider between tests.""" + yield + trace._TRACER_PROVIDER_SET_ONCE._done = False + trace._TRACER_PROVIDER = None diff --git a/secretmanager/snippets/requirements-test.txt b/secretmanager/snippets/requirements-test.txt index d9ad1f716cc..3ade3d7b635 100644 --- a/secretmanager/snippets/requirements-test.txt +++ b/secretmanager/snippets/requirements-test.txt @@ -1 +1,2 @@ -pytest==9.0.3 \ No newline at end of file +pytest==9.0.3 +pytest-asyncio>=0.23.0 \ No newline at end of file diff --git a/secretmanager/snippets/requirements.txt b/secretmanager/snippets/requirements.txt index 2e6bd673f37..1d2d7279fee 100644 --- a/secretmanager/snippets/requirements.txt +++ b/secretmanager/snippets/requirements.txt @@ -2,3 +2,6 @@ protobuf==6.33.6 google-cloud-resource-manager==1.18.0 google-cloud-secret-manager==2.29.0 google-crc32c==1.8.0 +opentelemetry-api>=1.20.0 +opentelemetry-sdk>=1.20.0 +opentelemetry-exporter-gcp-trace>=1.7.0 diff --git a/secretmanager/snippets/secretmanager_trace.py b/secretmanager/snippets/secretmanager_trace.py new file mode 100644 index 00000000000..4be691547ee --- /dev/null +++ b/secretmanager/snippets/secretmanager_trace.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Sample demonstrating OpenTelemetry distributed tracing with SecretManagerServiceClient.""" + +# [START secretmanager_enable_tracing] +import os + +from google.cloud import secretmanager +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor + + +def access_secret_version_with_tracing( + project_id: str, + secret_id: str, + version_id: str = "latest", +) -> str: + """Access a secret version in Secret Manager with OpenTelemetry tracing enabled. + + Args: + project_id: Google Cloud project ID. + secret_id: ID of the secret to access. + version_id: ID of the secret version to access (defaults to "latest"). + + Returns: + The secret payload decoded as a UTF-8 string. + """ + # 1. Initialize the OpenTelemetry TracerProvider with a Console exporter + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) + trace.set_tracer_provider(provider) + + # 2. Enable Google Cloud client library tracing + os.environ["GOOGLE_SDK_PYTHON_TRACING_ENABLED"] = "true" + + # 3. Instantiate the client (automatically discovers the active TracerProvider) + client = secretmanager.SecretManagerServiceClient() + + # 4. Construct the resource name and invoke the RPC + name = f"projects/{project_id}/secrets/{secret_id}/versions/{version_id}" + + try: + response = client.access_secret_version(request={"name": name}) + payload = response.payload.data.decode("UTF-8") + return payload + finally: + # Flush buffered spans before program completion + provider.shutdown() + + +# [END secretmanager_enable_tracing] + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="Access a Secret Manager secret version with OpenTelemetry tracing." + ) + parser.add_argument("--project-id", required=True, help="Google Cloud Project ID") + parser.add_argument("--secret-id", required=True, help="Secret ID") + parser.add_argument("--version-id", default="latest", help="Secret version ID") + + args = parser.parse_args() + secret_data = access_secret_version_with_tracing( + project_id=args.project_id, + secret_id=args.secret_id, + version_id=args.version_id, + ) + print(f"Retrieved secret payload: {secret_data}") diff --git a/secretmanager/snippets/secretmanager_trace_async.py b/secretmanager/snippets/secretmanager_trace_async.py new file mode 100644 index 00000000000..e3ec0e795d5 --- /dev/null +++ b/secretmanager/snippets/secretmanager_trace_async.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Sample demonstrating async OpenTelemetry distributed tracing with SecretManagerServiceAsyncClient.""" + +# [START secretmanager_enable_tracing_async] +import asyncio +import os + +from google.cloud import secretmanager_v1 +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor + + +async def access_secret_version_with_tracing_async( + project_id: str, + secret_id: str, + version_id: str = "latest", +) -> str: + """Access a secret version using SecretManagerServiceAsyncClient with OpenTelemetry tracing. + + Args: + project_id: Google Cloud project ID. + secret_id: ID of the secret to access. + version_id: ID of the secret version to access (defaults to "latest"). + + Returns: + The secret payload decoded as a UTF-8 string. + """ + # 1. Initialize the OpenTelemetry TracerProvider with a Console exporter + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) + trace.set_tracer_provider(provider) + + # 2. Enable Google Cloud client library tracing + os.environ["GOOGLE_SDK_PYTHON_TRACING_ENABLED"] = "true" + + # 3. Instantiate the async client + client = secretmanager_v1.SecretManagerServiceAsyncClient() + + # 4. Construct resource name and invoke the async RPC + name = f"projects/{project_id}/secrets/{secret_id}/versions/{version_id}" + + try: + response = await client.access_secret_version(request={"name": name}) + payload = response.payload.data.decode("UTF-8") + return payload + finally: + # Flush buffered spans before program completion + provider.shutdown() + + +# [END secretmanager_enable_tracing_async] + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="Access a Secret Manager secret version asynchronously with OpenTelemetry tracing." + ) + parser.add_argument("--project-id", required=True, help="Google Cloud Project ID") + parser.add_argument("--secret-id", required=True, help="Secret ID") + parser.add_argument("--version-id", default="latest", help="Secret version ID") + + args = parser.parse_args() + secret_data = asyncio.run( + access_secret_version_with_tracing_async( + project_id=args.project_id, + secret_id=args.secret_id, + version_id=args.version_id, + ) + ) + print(f"Retrieved secret payload asynchronously: {secret_data}") diff --git a/secretmanager/snippets/secretmanager_trace_async_test.py b/secretmanager/snippets/secretmanager_trace_async_test.py new file mode 100644 index 00000000000..d9deb43d5d8 --- /dev/null +++ b/secretmanager/snippets/secretmanager_trace_async_test.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for secretmanager_trace_async snippet.""" + +import concurrent.futures +import os +from unittest import mock + +import grpc +import pytest +from google.api_core import _observability +from google.api_core.client_options import ClientOptions +from google.api_core.gapic_v1 import method_async +from google.cloud import secretmanager +from google.cloud.secretmanager_v1.services.secret_manager_service.transports.grpc_asyncio import ( + SecretManagerServiceGrpcAsyncIOTransport, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + +from secretmanager_trace_async import ( + access_secret_version_with_tracing_async, +) + +# Ensure OTel tracing capabilities are enabled in the environment +os.environ["GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED"] = "true" +os.environ["GOOGLE_SDK_PYTHON_TRACING_ENABLED"] = "true" + + +@pytest.fixture +def mock_grpc_server(): + """Starts an in-memory gRPC server returning mock responses.""" + server = grpc.server(concurrent.futures.ThreadPoolExecutor(max_workers=2)) + port = server.add_insecure_port("127.0.0.1:0") + + mock_resp = secretmanager.AccessSecretVersionResponse( + name="projects/test-project/secrets/my-secret/versions/1", + payload=secretmanager.SecretPayload(data=b"mock-async-token-xyz"), + ) + + generic_handler = grpc.method_handlers_generic_handler( + "google.cloud.secretmanager.v1.SecretManagerService", + { + "AccessSecretVersion": grpc.unary_unary_rpc_method_handler( + lambda r, c: secretmanager.AccessSecretVersionResponse.serialize( + mock_resp + ), + request_deserializer=lambda b: b, + response_serializer=lambda b: b, + ) + }, + ) + server.add_generic_rpc_handlers((generic_handler,)) + server.start() + try: + yield port + finally: + server.stop(grace=None) + + +@pytest.fixture +def otel_setup(): + """Configures an in-memory OpenTelemetry tracer provider and exporter.""" + provider = TracerProvider() + exporter = InMemorySpanExporter() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + return provider, exporter + + +@pytest.mark.asyncio +async def test_access_secret_version_with_tracing_async_spans( + mock_grpc_server, otel_setup +): + """Verifies that access_secret_version_with_tracing_async emits T3 and T4 spans.""" + port = mock_grpc_server + provider, exporter = otel_setup + + orig_init = SecretManagerServiceGrpcAsyncIOTransport.__init__ + + def patched_init(self, *args, **kwargs): + opts = ClientOptions(api_endpoint=f"127.0.0.1:{port}", tracer_provider=provider) + interceptors = _observability.get_otel_async_interceptor(opts) + kwargs["channel"] = grpc.aio.insecure_channel( + f"127.0.0.1:{port}", interceptors=interceptors + ) + orig_init(self, *args, **kwargs) + # Ensure modern GAPIC async tracing wrapper is attached + self._wrapped_methods[self.access_secret_version] = method_async.wrap_method( + self.access_secret_version, + method_name="/google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion", + client_options=opts, + kind="grpc_asyncio", + ) + + with mock.patch("secretmanager_trace_async.TracerProvider", return_value=provider): + with mock.patch.object( + SecretManagerServiceGrpcAsyncIOTransport, "__init__", patched_init + ): + payload = await access_secret_version_with_tracing_async( + project_id="test-project", + secret_id="my-secret", + version_id="1", + ) + + # 1. Verify returned payload + assert payload == "mock-async-token-xyz" + + # 2. Inspect emitted spans + spans = exporter.get_finished_spans() + assert len(spans) == 2, f"Expected 2 spans (T3 + T4), but got {len(spans)}" + + t3_spans = [s for s in spans if s.parent is None] + t4_spans = [s for s in spans if s.parent is not None] + + assert len(t3_spans) == 1, "Expected exactly 1 T3 (logical method) span" + assert len(t4_spans) == 1, "Expected exactly 1 T4 (wire attempt) span" + + t3_span = t3_spans[0] + t4_span = t4_spans[0] + + # 3. Verify T3 logical client span attributes + assert ( + t3_span.name + == "google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion" + ) + assert t3_span.attributes.get("rpc.system.name") == "grpc" + assert t3_span.attributes.get("rpc.response.status_code") == "OK" + + # 4. Verify T4 wire attempt span attributes + assert ( + t4_span.name + == "google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion" + ) + assert ( + t4_span.attributes.get("rpc.service") + == "google.cloud.secretmanager.v1.SecretManagerService" + ) + assert t4_span.attributes.get("rpc.grpc.status_code") == 0 + assert t4_span.attributes.get("rpc.response.status_code") == "OK" + + # 5. Verify span hierarchy / parent-child linkage + assert t4_span.parent.span_id == t3_span.context.span_id diff --git a/secretmanager/snippets/secretmanager_trace_client_options.py b/secretmanager/snippets/secretmanager_trace_client_options.py new file mode 100644 index 00000000000..d002e0c5ce0 --- /dev/null +++ b/secretmanager/snippets/secretmanager_trace_client_options.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Sample demonstrating programmatic OpenTelemetry configuration via ClientOptions with SecretManagerServiceClient.""" + +# [START secretmanager_trace_client_options] +from google.api_core.client_options import ClientOptions +from google.cloud import secretmanager +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor + + +def access_secret_version_with_client_options( + project_id: str, + secret_id: str, + version_id: str = "latest", +) -> str: + """Access a secret version configuring tracing programmatically via ClientOptions. + + Args: + project_id: Google Cloud project ID. + secret_id: ID of the secret to access. + version_id: ID of the secret version to access (defaults to "latest"). + + Returns: + The secret payload decoded as a UTF-8 string. + """ + # 1. Initialize an independent TracerProvider (no global registration required) + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) + + # 2. Pass the TracerProvider directly via ClientOptions + # Tracing is activated specifically for this client instance, even without + # setting the GOOGLE_SDK_PYTHON_TRACING_ENABLED environment variable. + options = ClientOptions(tracer_provider=provider) + client = secretmanager.SecretManagerServiceClient(client_options=options) + + # 3. Construct resource name and invoke RPC + name = f"projects/{project_id}/secrets/{secret_id}/versions/{version_id}" + + try: + response = client.access_secret_version(request={"name": name}) + payload = response.payload.data.decode("UTF-8") + return payload + finally: + # Flush buffered spans before program completion + provider.shutdown() + + +# [END secretmanager_trace_client_options] + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="Access a Secret Manager secret version with ClientOptions-configured tracing." + ) + parser.add_argument("--project-id", required=True, help="Google Cloud Project ID") + parser.add_argument("--secret-id", required=True, help="Secret ID") + parser.add_argument("--version-id", default="latest", help="Secret version ID") + + args = parser.parse_args() + secret_data = access_secret_version_with_client_options( + project_id=args.project_id, + secret_id=args.secret_id, + version_id=args.version_id, + ) + print(f"Retrieved secret payload: {secret_data}") diff --git a/secretmanager/snippets/secretmanager_trace_client_options_test.py b/secretmanager/snippets/secretmanager_trace_client_options_test.py new file mode 100644 index 00000000000..d443ce7e58e --- /dev/null +++ b/secretmanager/snippets/secretmanager_trace_client_options_test.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for secretmanager_trace_client_options snippet.""" + +import concurrent.futures +import os +from unittest import mock + +import grpc +import pytest +from google.api_core.gapic_v1 import method +from google.cloud import secretmanager +from google.cloud.secretmanager_v1.services.secret_manager_service.transports.grpc import ( + SecretManagerServiceGrpcTransport, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + +from secretmanager_trace_client_options import access_secret_version_with_client_options + +os.environ["GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED"] = "true" + + +@pytest.fixture +def mock_grpc_server(): + """Starts an in-memory gRPC server returning mock responses.""" + server = grpc.server(concurrent.futures.ThreadPoolExecutor(max_workers=2)) + port = server.add_insecure_port("127.0.0.1:0") + + mock_resp = secretmanager.AccessSecretVersionResponse( + name="projects/test-project/secrets/my-secret/versions/1", + payload=secretmanager.SecretPayload(data=b"mock-client-options-token"), + ) + + generic_handler = grpc.method_handlers_generic_handler( + "google.cloud.secretmanager.v1.SecretManagerService", + { + "AccessSecretVersion": grpc.unary_unary_rpc_method_handler( + lambda r, c: secretmanager.AccessSecretVersionResponse.serialize( + mock_resp + ), + request_deserializer=lambda b: b, + response_serializer=lambda b: b, + ) + }, + ) + server.add_generic_rpc_handlers((generic_handler,)) + server.start() + try: + yield port + finally: + server.stop(grace=None) + + +def test_access_secret_version_with_client_options(mock_grpc_server, monkeypatch): + """Verifies that access_secret_version_with_client_options emits spans via ClientOptions.""" + port = mock_grpc_server + provider = TracerProvider() + exporter = InMemorySpanExporter() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + + orig_init = SecretManagerServiceGrpcTransport.__init__ + + def patched_init(self, *args, **kwargs): + kwargs["channel"] = grpc.insecure_channel(f"127.0.0.1:{port}") + client_opts = kwargs.get("client_options") + orig_init(self, *args, **kwargs) + if client_opts and client_opts.tracer_provider: + self._wrapped_methods[self.access_secret_version] = method.wrap_method( + self.access_secret_version, + method_name="/google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion", + client_options=client_opts, + kind="grpc", + ) + + monkeypatch.setattr(SecretManagerServiceGrpcTransport, "__init__", patched_init) + monkeypatch.delenv("GOOGLE_SDK_PYTHON_TRACING_ENABLED", raising=False) + + with mock.patch( + "secretmanager_trace_client_options.TracerProvider", return_value=provider + ): + payload = access_secret_version_with_client_options( + project_id="test-project", + secret_id="my-secret", + version_id="1", + ) + + assert payload == "mock-client-options-token" + + spans = exporter.get_finished_spans() + assert len(spans) >= 1 + t3_span = spans[-1] + assert "AccessSecretVersion" in t3_span.name + assert t3_span.status.is_ok diff --git a/secretmanager/snippets/secretmanager_trace_cloud_trace.py b/secretmanager/snippets/secretmanager_trace_cloud_trace.py new file mode 100644 index 00000000000..601dc4dffb3 --- /dev/null +++ b/secretmanager/snippets/secretmanager_trace_cloud_trace.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Sample demonstrating exporting OpenTelemetry traces to Google Cloud Trace with SecretManagerServiceClient.""" + +# [START secretmanager_trace_cloud_trace] +import os + +from google.cloud import secretmanager +from opentelemetry import trace +from opentelemetry.exporter.cloud_trace import CloudTraceSpanExporter +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + + +def access_secret_version_cloud_trace( + project_id: str, + secret_id: str, + version_id: str = "latest", +) -> str: + """Access a secret version in Secret Manager exporting traces to Google Cloud Trace. + + Args: + project_id: Google Cloud project ID. + secret_id: ID of the secret to access. + version_id: ID of the secret version to access (defaults to "latest"). + + Returns: + The secret payload decoded as a UTF-8 string. + """ + # 1. Initialize OpenTelemetry TracerProvider with CloudTraceSpanExporter and BatchSpanProcessor + provider = TracerProvider() + cloud_exporter = CloudTraceSpanExporter() + provider.add_span_processor(BatchSpanProcessor(cloud_exporter)) + trace.set_tracer_provider(provider) + + # 2. Enable Google Cloud client library tracing + os.environ["GOOGLE_SDK_PYTHON_TRACING_ENABLED"] = "true" + + # 3. Instantiate the client (automatically discovers the active TracerProvider) + client = secretmanager.SecretManagerServiceClient() + + # 4. Construct resource name and invoke RPC + name = f"projects/{project_id}/secrets/{secret_id}/versions/{version_id}" + + try: + response = client.access_secret_version(request={"name": name}) + payload = response.payload.data.decode("UTF-8") + return payload + finally: + # Flush buffered spans to Cloud Trace before exiting + provider.shutdown() + + +# [END secretmanager_trace_cloud_trace] + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="Access a Secret Manager secret version and export traces to Google Cloud Trace." + ) + parser.add_argument("--project-id", required=True, help="Google Cloud Project ID") + parser.add_argument("--secret-id", required=True, help="Secret ID") + parser.add_argument("--version-id", default="latest", help="Secret version ID") + + args = parser.parse_args() + secret_data = access_secret_version_cloud_trace( + project_id=args.project_id, + secret_id=args.secret_id, + version_id=args.version_id, + ) + print(f"Retrieved secret payload: {secret_data}") diff --git a/secretmanager/snippets/secretmanager_trace_cloud_trace_test.py b/secretmanager/snippets/secretmanager_trace_cloud_trace_test.py new file mode 100644 index 00000000000..ff7d91d90b8 --- /dev/null +++ b/secretmanager/snippets/secretmanager_trace_cloud_trace_test.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for secretmanager_trace_cloud_trace snippet.""" + +import concurrent.futures +import os +import sys +from unittest import mock + +import grpc +import pytest +from google.cloud import secretmanager +from google.cloud.secretmanager_v1.services.secret_manager_service.transports.grpc import ( + SecretManagerServiceGrpcTransport, +) +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + + +# Mock opentelemetry.exporter.cloud_trace if not installed in the environment +class MockCloudTraceSpanExporter(SpanExporter): + def __init__(self, *args, **kwargs): + self.exported_spans = [] + + def export(self, spans): + self.exported_spans.extend(spans) + return SpanExportResult.SUCCESS + + def shutdown(self): + pass + + +if "opentelemetry.exporter.cloud_trace" not in sys.modules: + mock_module = mock.MagicMock() + mock_module.CloudTraceSpanExporter = MockCloudTraceSpanExporter + sys.modules["opentelemetry.exporter.cloud_trace"] = mock_module + +# flake8: E402 is ignored here because we must patch sys.modules with a mock +# CloudTraceSpanExporter before importing the snippet module in environments +# where opentelemetry-exporter-gcp-trace is not installed. +from secretmanager_trace_cloud_trace import ( # noqa: E402 + access_secret_version_cloud_trace, +) + +os.environ["GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED"] = "true" +os.environ["GOOGLE_SDK_PYTHON_TRACING_ENABLED"] = "true" + + +@pytest.fixture +def mock_grpc_server(): + """Starts an in-memory gRPC server returning mock responses.""" + server = grpc.server(concurrent.futures.ThreadPoolExecutor(max_workers=2)) + port = server.add_insecure_port("127.0.0.1:0") + + mock_resp = secretmanager.AccessSecretVersionResponse( + name="projects/test-project/secrets/my-secret/versions/1", + payload=secretmanager.SecretPayload(data=b"mock-cloud-trace-secret"), + ) + + generic_handler = grpc.method_handlers_generic_handler( + "google.cloud.secretmanager.v1.SecretManagerService", + { + "AccessSecretVersion": grpc.unary_unary_rpc_method_handler( + lambda r, c: secretmanager.AccessSecretVersionResponse.serialize( + mock_resp + ), + request_deserializer=lambda b: b, + response_serializer=lambda b: b, + ) + }, + ) + server.add_generic_rpc_handlers((generic_handler,)) + server.start() + try: + yield port + finally: + server.stop(grace=None) + + +def test_access_secret_version_cloud_trace(mock_grpc_server, monkeypatch): + """Verifies that access_secret_version_cloud_trace initializes CloudTraceSpanExporter and runs.""" + port = mock_grpc_server + + orig_init = SecretManagerServiceGrpcTransport.__init__ + + def patched_init(self, *args, **kwargs): + kwargs["channel"] = grpc.insecure_channel(f"127.0.0.1:{port}") + orig_init(self, *args, **kwargs) + + monkeypatch.setattr(SecretManagerServiceGrpcTransport, "__init__", patched_init) + + with mock.patch( + "secretmanager_trace_cloud_trace.CloudTraceSpanExporter" + ) as mock_cloud_exporter_cls: + instance = MockCloudTraceSpanExporter() + mock_cloud_exporter_cls.return_value = instance + + payload = access_secret_version_cloud_trace( + project_id="test-project", + secret_id="my-secret", + version_id="1", + ) + + assert payload == "mock-cloud-trace-secret" + mock_cloud_exporter_cls.assert_called_once() diff --git a/secretmanager/snippets/secretmanager_trace_test.py b/secretmanager/snippets/secretmanager_trace_test.py new file mode 100644 index 00000000000..6896648b2d0 --- /dev/null +++ b/secretmanager/snippets/secretmanager_trace_test.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for secretmanager_trace snippet.""" + +import concurrent.futures +import os +from unittest import mock + +import grpc +import pytest +from google.api_core.client_options import ClientOptions +from google.api_core.gapic_v1 import method +from google.cloud import secretmanager +from google.cloud.secretmanager_v1.services.secret_manager_service.transports.grpc import ( + SecretManagerServiceGrpcTransport, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + +from secretmanager_trace import access_secret_version_with_tracing + +# Ensure OTel tracing capabilities are enabled in the environment +os.environ["GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED"] = "true" +os.environ["GOOGLE_SDK_PYTHON_TRACING_ENABLED"] = "true" + + +@pytest.fixture +def mock_grpc_server(): + """Starts an in-memory gRPC server returning mock responses.""" + server = grpc.server(concurrent.futures.ThreadPoolExecutor(max_workers=2)) + port = server.add_insecure_port("127.0.0.1:0") + + mock_resp = secretmanager.AccessSecretVersionResponse( + name="projects/test-project/secrets/my-secret/versions/1", + payload=secretmanager.SecretPayload(data=b"mock-secret-token-123"), + ) + + generic_handler = grpc.method_handlers_generic_handler( + "google.cloud.secretmanager.v1.SecretManagerService", + { + "AccessSecretVersion": grpc.unary_unary_rpc_method_handler( + lambda r, c: secretmanager.AccessSecretVersionResponse.serialize( + mock_resp + ), + request_deserializer=lambda b: b, + response_serializer=lambda b: b, + ) + }, + ) + server.add_generic_rpc_handlers((generic_handler,)) + server.start() + try: + yield port + finally: + server.stop(grace=None) + + +@pytest.fixture +def otel_setup(): + """Configures an in-memory OpenTelemetry tracer provider and exporter.""" + provider = TracerProvider() + exporter = InMemorySpanExporter() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + return provider, exporter + + +def test_access_secret_version_with_tracing_spans(mock_grpc_server, otel_setup): + """Verifies that access_secret_version_with_tracing emits T3 and T4 spans.""" + port = mock_grpc_server + provider, exporter = otel_setup + + orig_init = SecretManagerServiceGrpcTransport.__init__ + + def patched_init(self, *args, **kwargs): + kwargs["channel"] = grpc.insecure_channel(f"127.0.0.1:{port}") + orig_init(self, *args, **kwargs) + # Ensure modern GAPIC tracing wrapper is attached to the transport callable + self._wrapped_methods[self.access_secret_version] = method.wrap_method( + self.access_secret_version, + method_name="/google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion", + client_options=ClientOptions(tracer_provider=provider), + kind="grpc", + ) + + with mock.patch("secretmanager_trace.TracerProvider", return_value=provider): + with mock.patch.object( + SecretManagerServiceGrpcTransport, "__init__", patched_init + ): + payload = access_secret_version_with_tracing( + project_id="test-project", + secret_id="my-secret", + version_id="1", + ) + + # 1. Verify returned payload + assert payload == "mock-secret-token-123" + + # 2. Inspect emitted spans + spans = exporter.get_finished_spans() + assert len(spans) == 2, f"Expected 2 spans (T3 + T4), but got {len(spans)}" + + t3_spans = [s for s in spans if s.parent is None] + t4_spans = [s for s in spans if s.parent is not None] + + assert len(t3_spans) == 1, "Expected exactly 1 T3 (logical method) span" + assert len(t4_spans) == 1, "Expected exactly 1 T4 (wire attempt) span" + + t3_span = t3_spans[0] + t4_span = t4_spans[0] + + # 3. Verify T3 logical client span attributes + assert ( + t3_span.name + == "google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion" + ) + assert t3_span.attributes.get("rpc.system.name") == "grpc" + assert t3_span.attributes.get("rpc.response.status_code") == "OK" + + # 4. Verify T4 wire attempt span attributes + assert ( + t4_span.name + == "google.cloud.secretmanager.v1.SecretManagerService/AccessSecretVersion" + ) + assert ( + t4_span.attributes.get("rpc.service") + == "google.cloud.secretmanager.v1.SecretManagerService" + ) + assert t4_span.attributes.get("rpc.grpc.status_code") == 0 + assert t4_span.attributes.get("rpc.response.status_code") == "OK" + + # 5. Verify span hierarchy / parent-child linkage + assert t4_span.parent.span_id == t3_span.context.span_id From 404f1910a561195e6cbae06d194036d791d8e116 Mon Sep 17 00:00:00 2001 From: chalmer lowe Date: Wed, 14 Oct 2026 17:43:27 -0400 Subject: [PATCH 2/2] fix(secretmanager): align import order for flake8 and standardize tracing snippets --- secretmanager/snippets/conftest.py | 2 +- secretmanager/snippets/secretmanager_trace.py | 13 ++++++++++--- secretmanager/snippets/secretmanager_trace_async.py | 13 ++++++++++--- .../snippets/secretmanager_trace_async_test.py | 4 ++-- .../snippets/secretmanager_trace_client_options.py | 8 +++++--- .../secretmanager_trace_client_options_test.py | 4 ++-- .../snippets/secretmanager_trace_cloud_trace.py | 13 ++++++++++--- .../secretmanager_trace_cloud_trace_test.py | 4 ++-- secretmanager/snippets/secretmanager_trace_test.py | 4 ++-- 9 files changed, 44 insertions(+), 21 deletions(-) diff --git a/secretmanager/snippets/conftest.py b/secretmanager/snippets/conftest.py index c1ad6634ad2..2d7fe643b56 100644 --- a/secretmanager/snippets/conftest.py +++ b/secretmanager/snippets/conftest.py @@ -15,8 +15,8 @@ """Shared test fixtures for Secret Manager tracing snippets.""" -import pytest from opentelemetry import trace +import pytest @pytest.fixture(autouse=True) diff --git a/secretmanager/snippets/secretmanager_trace.py b/secretmanager/snippets/secretmanager_trace.py index 4be691547ee..3fb8c24d5c8 100644 --- a/secretmanager/snippets/secretmanager_trace.py +++ b/secretmanager/snippets/secretmanager_trace.py @@ -39,6 +39,11 @@ def access_secret_version_with_tracing( Returns: The secret payload decoded as a UTF-8 string. """ + # NOTE: In standalone scripts or CLI tools, initialize and shut down the + # TracerProvider within the script workflow. In long-running production + # applications (e.g., web servers), configure the TracerProvider once at + # application startup. + # # 1. Initialize the OpenTelemetry TracerProvider with a Console exporter provider = TracerProvider() provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) @@ -71,9 +76,11 @@ def access_secret_version_with_tracing( parser = argparse.ArgumentParser( description="Access a Secret Manager secret version with OpenTelemetry tracing." ) - parser.add_argument("--project-id", required=True, help="Google Cloud Project ID") - parser.add_argument("--secret-id", required=True, help="Secret ID") - parser.add_argument("--version-id", default="latest", help="Secret version ID") + parser.add_argument("project_id", help="id of the GCP project") + parser.add_argument("secret_id", help="id of the secret to access") + parser.add_argument( + "version_id", default="latest", nargs="?", help="version to access" + ) args = parser.parse_args() secret_data = access_secret_version_with_tracing( diff --git a/secretmanager/snippets/secretmanager_trace_async.py b/secretmanager/snippets/secretmanager_trace_async.py index e3ec0e795d5..b7f13ff80db 100644 --- a/secretmanager/snippets/secretmanager_trace_async.py +++ b/secretmanager/snippets/secretmanager_trace_async.py @@ -40,6 +40,11 @@ async def access_secret_version_with_tracing_async( Returns: The secret payload decoded as a UTF-8 string. """ + # NOTE: In standalone scripts or CLI tools, initialize and shut down the + # TracerProvider within the script workflow. In long-running production + # applications (e.g., web servers), configure the TracerProvider once at + # application startup. + # # 1. Initialize the OpenTelemetry TracerProvider with a Console exporter provider = TracerProvider() provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) @@ -72,9 +77,11 @@ async def access_secret_version_with_tracing_async( parser = argparse.ArgumentParser( description="Access a Secret Manager secret version asynchronously with OpenTelemetry tracing." ) - parser.add_argument("--project-id", required=True, help="Google Cloud Project ID") - parser.add_argument("--secret-id", required=True, help="Secret ID") - parser.add_argument("--version-id", default="latest", help="Secret version ID") + parser.add_argument("project_id", help="id of the GCP project") + parser.add_argument("secret_id", help="id of the secret to access") + parser.add_argument( + "version_id", default="latest", nargs="?", help="version to access" + ) args = parser.parse_args() secret_data = asyncio.run( diff --git a/secretmanager/snippets/secretmanager_trace_async_test.py b/secretmanager/snippets/secretmanager_trace_async_test.py index d9deb43d5d8..fc1914dd6a8 100644 --- a/secretmanager/snippets/secretmanager_trace_async_test.py +++ b/secretmanager/snippets/secretmanager_trace_async_test.py @@ -19,8 +19,6 @@ import os from unittest import mock -import grpc -import pytest from google.api_core import _observability from google.api_core.client_options import ClientOptions from google.api_core.gapic_v1 import method_async @@ -28,11 +26,13 @@ from google.cloud.secretmanager_v1.services.secret_manager_service.transports.grpc_asyncio import ( SecretManagerServiceGrpcAsyncIOTransport, ) +import grpc from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( InMemorySpanExporter, ) +import pytest from secretmanager_trace_async import ( access_secret_version_with_tracing_async, diff --git a/secretmanager/snippets/secretmanager_trace_client_options.py b/secretmanager/snippets/secretmanager_trace_client_options.py index d002e0c5ce0..cbe2c0adb99 100644 --- a/secretmanager/snippets/secretmanager_trace_client_options.py +++ b/secretmanager/snippets/secretmanager_trace_client_options.py @@ -68,9 +68,11 @@ def access_secret_version_with_client_options( parser = argparse.ArgumentParser( description="Access a Secret Manager secret version with ClientOptions-configured tracing." ) - parser.add_argument("--project-id", required=True, help="Google Cloud Project ID") - parser.add_argument("--secret-id", required=True, help="Secret ID") - parser.add_argument("--version-id", default="latest", help="Secret version ID") + parser.add_argument("project_id", help="id of the GCP project") + parser.add_argument("secret_id", help="id of the secret to access") + parser.add_argument( + "version_id", default="latest", nargs="?", help="version to access" + ) args = parser.parse_args() secret_data = access_secret_version_with_client_options( diff --git a/secretmanager/snippets/secretmanager_trace_client_options_test.py b/secretmanager/snippets/secretmanager_trace_client_options_test.py index d443ce7e58e..6b31ae2342c 100644 --- a/secretmanager/snippets/secretmanager_trace_client_options_test.py +++ b/secretmanager/snippets/secretmanager_trace_client_options_test.py @@ -19,18 +19,18 @@ import os from unittest import mock -import grpc -import pytest from google.api_core.gapic_v1 import method from google.cloud import secretmanager from google.cloud.secretmanager_v1.services.secret_manager_service.transports.grpc import ( SecretManagerServiceGrpcTransport, ) +import grpc from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( InMemorySpanExporter, ) +import pytest from secretmanager_trace_client_options import access_secret_version_with_client_options diff --git a/secretmanager/snippets/secretmanager_trace_cloud_trace.py b/secretmanager/snippets/secretmanager_trace_cloud_trace.py index 601dc4dffb3..de8b3c9b79d 100644 --- a/secretmanager/snippets/secretmanager_trace_cloud_trace.py +++ b/secretmanager/snippets/secretmanager_trace_cloud_trace.py @@ -40,6 +40,11 @@ def access_secret_version_cloud_trace( Returns: The secret payload decoded as a UTF-8 string. """ + # NOTE: In standalone scripts or CLI tools, initialize and shut down the + # TracerProvider within the script workflow. In long-running production + # applications (e.g., web servers), configure the TracerProvider once at + # application startup. + # # 1. Initialize OpenTelemetry TracerProvider with CloudTraceSpanExporter and BatchSpanProcessor provider = TracerProvider() cloud_exporter = CloudTraceSpanExporter() @@ -73,9 +78,11 @@ def access_secret_version_cloud_trace( parser = argparse.ArgumentParser( description="Access a Secret Manager secret version and export traces to Google Cloud Trace." ) - parser.add_argument("--project-id", required=True, help="Google Cloud Project ID") - parser.add_argument("--secret-id", required=True, help="Secret ID") - parser.add_argument("--version-id", default="latest", help="Secret version ID") + parser.add_argument("project_id", help="id of the GCP project") + parser.add_argument("secret_id", help="id of the secret to access") + parser.add_argument( + "version_id", default="latest", nargs="?", help="version to access" + ) args = parser.parse_args() secret_data = access_secret_version_cloud_trace( diff --git a/secretmanager/snippets/secretmanager_trace_cloud_trace_test.py b/secretmanager/snippets/secretmanager_trace_cloud_trace_test.py index ff7d91d90b8..2e99b4f5c45 100644 --- a/secretmanager/snippets/secretmanager_trace_cloud_trace_test.py +++ b/secretmanager/snippets/secretmanager_trace_cloud_trace_test.py @@ -20,13 +20,13 @@ import sys from unittest import mock -import grpc -import pytest from google.cloud import secretmanager from google.cloud.secretmanager_v1.services.secret_manager_service.transports.grpc import ( SecretManagerServiceGrpcTransport, ) +import grpc from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult +import pytest # Mock opentelemetry.exporter.cloud_trace if not installed in the environment diff --git a/secretmanager/snippets/secretmanager_trace_test.py b/secretmanager/snippets/secretmanager_trace_test.py index 6896648b2d0..8bd05dee5d5 100644 --- a/secretmanager/snippets/secretmanager_trace_test.py +++ b/secretmanager/snippets/secretmanager_trace_test.py @@ -19,19 +19,19 @@ import os from unittest import mock -import grpc -import pytest from google.api_core.client_options import ClientOptions from google.api_core.gapic_v1 import method from google.cloud import secretmanager from google.cloud.secretmanager_v1.services.secret_manager_service.transports.grpc import ( SecretManagerServiceGrpcTransport, ) +import grpc from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( InMemorySpanExporter, ) +import pytest from secretmanager_trace import access_secret_version_with_tracing