diff --git a/.gitignore b/.gitignore index d0b21a34..fbe27547 100644 --- a/.gitignore +++ b/.gitignore @@ -115,6 +115,7 @@ venv/ ENV/ env.bak/ venv.bak/ +myproject/ # Spyder project settings .spyderproject diff --git a/opentelemetry-exporter-gcp-logging/CHANGELOG.md b/opentelemetry-exporter-gcp-logging/CHANGELOG.md new file mode 100644 index 00000000..1512c421 --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +## Unreleased diff --git a/opentelemetry-exporter-gcp-logging/LICENSE b/opentelemetry-exporter-gcp-logging/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/opentelemetry-exporter-gcp-logging/MANIFEST.in b/opentelemetry-exporter-gcp-logging/MANIFEST.in new file mode 100644 index 00000000..aed3e332 --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/MANIFEST.in @@ -0,0 +1,9 @@ +graft src +graft tests +global-exclude *.pyc +global-exclude *.pyo +global-exclude __pycache__/* +include CHANGELOG.md +include MANIFEST.in +include README.rst +include LICENSE diff --git a/opentelemetry-exporter-gcp-logging/README.rst b/opentelemetry-exporter-gcp-logging/README.rst new file mode 100644 index 00000000..9fa6a4d9 --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/README.rst @@ -0,0 +1,74 @@ +OpenTelemetry Google Cloud Logging Exporter +============================================== + +.. image:: https://badge.fury.io/py/opentelemetry-exporter-gcp-logging.svg + :target: https://badge.fury.io/py/opentelemetry-exporter-gcp-logging + +.. image:: https://readthedocs.org/projects/google-cloud-opentelemetry/badge/?version=latest + :target: https://google-cloud-opentelemetry.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status + +This library provides support for exporting logs to Google Cloud +Logging. + +To get started with instrumentation in Google Cloud, see `Generate traces and metrics with +Python `_. + +To learn more about instrumentation and observability, including opinionated recommendations +for Google Cloud Observability, visit `Instrumentation and observability +`_. + +For resource detection and GCP trace context propagation, see +`opentelemetry-tools-google-cloud +`_. For the +Google Cloud Trace exporter, see `opentelemetry-exporter-gcp-trace +`_. + +Installation +------------ + +.. code:: bash + + pip install opentelemetry-exporter-gcp-logging + +Usage +----- + +.. code:: python + import logging + from opentelemetry.exporter.cloud_logging import ( + CloudLoggingExporter, + ) + from opentelemetry.sdk.resources import Resource + from opentelemetry._logs import set_logger_provider + from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler + from opentelemetry.sdk._logs.export import BatchLogRecordProcessor + + logger_provider = LoggerProvider( + resource=Resource.create( + { + "service.name": "shoppingcart", + "service.instance.id": "instance-12", + } + ), + ) + set_logger_provider(logger_provider) + exporter = CloudLoggingExporter(default_log_name='my_log') + logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter)) + handler = LoggingHandler(level=logging.ERROR, logger_provider=logger_provider) + + # Attach OTLP handler to root logger + logging.getLogger().addHandler(handler) + + # Create namespaced logger + # It is recommended to not use the root logger with OTLP handler + # so telemetry is collected only for the application + logger1 = logging.getLogger("myapp.area1") + + logger1.warning("string log %s", "here") + +References +---------- + +* `Cloud Logging `_ +* `OpenTelemetry Project `_ diff --git a/opentelemetry-exporter-gcp-logging/mypy.ini b/opentelemetry-exporter-gcp-logging/mypy.ini new file mode 100644 index 00000000..f6455a4e --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/mypy.ini @@ -0,0 +1,7 @@ +[mypy] +namespace_packages = True +explicit_package_bases = True +mypy_path = $MYPY_CONFIG_FILE_DIR/src + +[mypy-google.auth.*] +ignore_missing_imports = True diff --git a/opentelemetry-exporter-gcp-logging/setup.cfg b/opentelemetry-exporter-gcp-logging/setup.cfg new file mode 100644 index 00000000..ff52b876 --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/setup.cfg @@ -0,0 +1,44 @@ +[metadata] +name = opentelemetry-exporter-gcp-logging +description = Google Cloud Logging exporter for OpenTelemetry +long_description = file: README.rst +long_description_content_type = text/x-rst +author = Google +author_email = opentelemetry-pypi@google.com +url = https://github.com/GoogleCloudPlatform/opentelemetry-operations-python/tree/main/opentelemetry-exporter-gcp-logging +platforms = any +license = Apache-2.0 +classifiers = + Development Status :: 4 - Beta + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 + +[options] +python_requires = >=3.7 +package_dir= + =src +packages=find_namespace: +install_requires = + google-cloud-logging ~= 3.0 + opentelemetry-sdk ~= 1.0 + opentelemetry-resourcedetector-gcp >= 1.5.0dev0, == 1.* + +[options.packages.find] +where = src + +[options.extras_require] +test = + +[options.entry_points] +opentelemetry_logs_exporter = + gcp_logging = opentelemetry.exporter.cloud_logging:CloudLoggingExporter +opentelemetry_environment_variables = + gcp_logging = opentelemetry.exporter.cloud_logging.environment_variables diff --git a/opentelemetry-exporter-gcp-logging/setup.py b/opentelemetry-exporter-gcp-logging/setup.py new file mode 100644 index 00000000..e57f525a --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/setup.py @@ -0,0 +1,34 @@ +# Copyright 2025 The OpenTelemetry Authors +# +# 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. +import os + +import setuptools + +BASE_DIR = os.path.dirname(__file__) +VERSION_FILENAME = os.path.join( + BASE_DIR, + "src", + "opentelemetry", + "exporter", + "cloud_logging", + "version.py", +) +PACKAGE_INFO = {} +with open(VERSION_FILENAME) as f: + exec(f.read(), PACKAGE_INFO) + +setuptools.setup( + version=PACKAGE_INFO["__version__"], + package_data={"opentelemetry": ["py.typed"]}, +) diff --git a/opentelemetry-exporter-gcp-logging/src/opentelemetry/exporter/cloud_logging/__init__.py b/opentelemetry-exporter-gcp-logging/src/opentelemetry/exporter/cloud_logging/__init__.py new file mode 100644 index 00000000..d1094f7c --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/src/opentelemetry/exporter/cloud_logging/__init__.py @@ -0,0 +1,263 @@ +# Copyright 2025 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. +from __future__ import annotations + +import base64 +import datetime +import json +import logging +import urllib.parse +from typing import Any, Mapping, Optional, Sequence + +import google.auth +from google.api.monitored_resource_pb2 import ( # pylint: disable = no-name-in-module + MonitoredResource, +) +from google.cloud.logging_v2.services.logging_service_v2 import ( + LoggingServiceV2Client, +) +from google.cloud.logging_v2.services.logging_service_v2.transports.grpc import ( + LoggingServiceV2GrpcTransport, +) +from google.cloud.logging_v2.types.log_entry import LogEntry +from google.cloud.logging_v2.types.logging import WriteLogEntriesRequest +from google.logging.type.log_severity_pb2 import ( # pylint: disable = no-name-in-module + LogSeverity, +) +from google.protobuf.struct_pb2 import ( # pylint: disable = no-name-in-module + Struct, +) +from google.protobuf.timestamp_pb2 import ( # pylint: disable = no-name-in-module + Timestamp, +) +from opentelemetry.exporter.cloud_logging.version import __version__ +from opentelemetry.resourcedetector.gcp_resource_detector._mapping import ( + get_monitored_resource, +) +from opentelemetry.sdk import version as opentelemetry_sdk_version +from opentelemetry.sdk._logs import LogData +from opentelemetry.sdk._logs.export import LogExporter +from opentelemetry.sdk.resources import Resource +from opentelemetry.trace import format_span_id, format_trace_id + +DEFAULT_MAX_ENTRY_SIZE = 256000 # 256 KB +DEFAULT_MAX_REQUEST_SIZE = 10000000 # 10 MB + +HTTP_REQUEST_ATTRIBUTE_KEY = "gcp.http_request" +LOG_NAME_ATTRIBUTE_KEY = "gcp.log_name" +SOURCE_LOCATION_ATTRIBUTE_KEY = "gcp.source_location" +TRACE_SAMPLED_ATTRIBUTE_KEY = "gcp.trace_sampled" +PROJECT_ID_ATTRIBUTE_KEY = "gcp.project_id" +_OTEL_SDK_VERSION = opentelemetry_sdk_version.__version__ +_USER_AGENT = f"opentelemetry-python {_OTEL_SDK_VERSION}; google-cloud-logging-exporter {__version__}" + +# Set user-agent metadata, see https://github.com/grpc/grpc/issues/23644 and default options +# from +# https://github.com/googleapis/python-logging/blob/5309478c054d0f2b9301817fd835f2098f51dc3a/google/cloud/logging_v2/services/logging_service_v2/transports/grpc.py#L179-L182 +_OPTIONS = [ + ("grpc.max_send_message_length", -1), + ("grpc.max_receive_message_length", -1), + ("grpc.primary_user_agent", _USER_AGENT), +] + +# severityMapping maps the integer severity level values from OTel [0-24] +# to matching Cloud Logging severity levels. +SEVERITY_MAPPING: dict[int, int] = { + 0: LogSeverity.DEFAULT, + 1: LogSeverity.DEBUG, + 2: LogSeverity.DEBUG, + 3: LogSeverity.DEBUG, + 4: LogSeverity.DEBUG, + 5: LogSeverity.DEBUG, + 6: LogSeverity.DEBUG, + 7: LogSeverity.DEBUG, + 8: LogSeverity.DEBUG, + 9: LogSeverity.INFO, + 10: LogSeverity.INFO, + 11: LogSeverity.NOTICE, + 12: LogSeverity.NOTICE, + 13: LogSeverity.WARNING, + 14: LogSeverity.WARNING, + 15: LogSeverity.WARNING, + 16: LogSeverity.WARNING, + 17: LogSeverity.ERROR, + 18: LogSeverity.ERROR, + 19: LogSeverity.ERROR, + 20: LogSeverity.ERROR, + 21: LogSeverity.CRITICAL, + 22: LogSeverity.CRITICAL, + 23: LogSeverity.ALERT, + 24: LogSeverity.EMERGENCY, +} + + +def _convert_any_value_to_string(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, bytes): + return base64.b64encode(value).decode() + if isinstance(value, (int, float, str)): + return str(value) + if isinstance(value, (list, tuple)): + return json.dumps(value) + logging.warning( + "Unknown value %s found, cannot convert to string.", type(value) + ) + return "" + + +def _set_payload_in_log_entry(log_entry: LogEntry, body: Any | None): + struct = Struct() + if isinstance(body, Mapping): + struct.update(body) + log_entry.json_payload = struct + elif isinstance(body, bytes): + json_str = body.decode("utf-8", errors="replace") + json_dict = json.loads(json_str) + if isinstance(json_dict, Mapping): + struct.update(json_dict) + log_entry.json_payload = struct + else: + log_entry.text_payload = base64.b64encode(body).decode() + else: + log_entry.text_payload = _convert_any_value_to_string(body) + + +class CloudLoggingExporter(LogExporter): + def __init__( + self, + project_id: Optional[str] = None, + default_log_name: Optional[str] = None, + client: Optional[LoggingServiceV2Client] = None, + ): + self.project_id: str + if not project_id: + _, default_project_id = google.auth.default() + self.project_id = str(default_project_id) + else: + self.project_id = project_id + if default_log_name: + self.default_log_name = default_log_name + else: + self.default_log_name = "otel_python_inprocess_log_name_temp" + self.client = client or LoggingServiceV2Client( + transport=LoggingServiceV2GrpcTransport( + channel=LoggingServiceV2GrpcTransport.create_channel( + options=_OPTIONS, + ) + ) + ) + + def export(self, batch: Sequence[LogData]): + now = datetime.datetime.now() + log_entries = [] + for log_data in batch: + log_entry = LogEntry() + log_record = log_data.log_record + attributes = log_record.attributes or {} + project_id = str( + attributes.get(PROJECT_ID_ATTRIBUTE_KEY, self.project_id) + ) + log_suffix = urllib.parse.quote_plus( + str( + attributes.get( + LOG_NAME_ATTRIBUTE_KEY, self.default_log_name + ) + ) + ) + log_entry.log_name = f"projects/{project_id}/logs/{log_suffix}" + # If timestamp is unset fall back to observed_time_unix_nano as recommended, + # see https://github.com/open-telemetry/opentelemetry-proto/blob/4abbb78/opentelemetry/proto/logs/v1/logs.proto#L176-L179 + ts = Timestamp() + if log_record.timestamp or log_record.observed_timestamp: + ts.FromNanoseconds( + log_record.timestamp or log_record.observed_timestamp + ) + else: + ts.FromDatetime(now) + log_entry.timestamp = ts + monitored_resource_data = get_monitored_resource( + log_record.resource or Resource({}) + ) + if monitored_resource_data: + log_entry.resource = MonitoredResource( + type=monitored_resource_data.type, + labels=monitored_resource_data.labels, + ) + log_entry.trace_sampled = ( + log_record.trace_flags is not None + and log_record.trace_flags.sampled + ) + if log_record.trace_id: + log_entry.trace = f"projects/{project_id}/traces/{format_trace_id(log_record.trace_id)}" + if log_record.span_id: + log_entry.span_id = format_span_id(log_record.span_id) + if ( + log_record.severity_number + and log_record.severity_number.value in SEVERITY_MAPPING + ): + log_entry.severity = SEVERITY_MAPPING[ # type: ignore[assignment] + log_record.severity_number.value # type: ignore[index] + ] + log_entry.labels = { + k: _convert_any_value_to_string(v) + for k, v in attributes.items() + } + _set_payload_in_log_entry(log_entry, log_record.body) + log_entries.append(log_entry) + + self._write_log_entries(log_entries) + + def _write_log_entries(self, log_entries: list[LogEntry]): + batch: list[LogEntry] = [] + batch_byte_size = 0 + for entry in log_entries: + msg_size = LogEntry.pb(entry).ByteSize() + if msg_size > DEFAULT_MAX_ENTRY_SIZE: + logging.warning( + "Cannot write log that is %s bytes which exceeds Cloud Logging's maximum limit of %s bytes.", + msg_size, + DEFAULT_MAX_ENTRY_SIZE, + ) + continue + if msg_size + batch_byte_size > DEFAULT_MAX_REQUEST_SIZE: + try: + self.client.write_log_entries( + WriteLogEntriesRequest( + entries=batch, partial_success=True + ) + ) + # pylint: disable=broad-except + except Exception as ex: + logging.error( + "Error while writing to Cloud Logging", exc_info=ex + ) + batch = [entry] + batch_byte_size = msg_size + else: + batch.append(entry) + batch_byte_size += msg_size + if batch: + try: + self.client.write_log_entries( + WriteLogEntriesRequest(entries=batch, partial_success=True) + ) + # pylint: disable=broad-except + except Exception as ex: + logging.error( + "Error while writing to Cloud Logging", exc_info=ex + ) + + def shutdown(self): + pass diff --git a/opentelemetry-exporter-gcp-logging/src/opentelemetry/exporter/cloud_logging/environment_variables.py b/opentelemetry-exporter-gcp-logging/src/opentelemetry/exporter/cloud_logging/environment_variables.py new file mode 100644 index 00000000..02d8b75e --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/src/opentelemetry/exporter/cloud_logging/environment_variables.py @@ -0,0 +1,21 @@ +# Copyright 2025 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 +# +# https://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. + +OTEL_EXPORTER_GCP_LOGGING_PROJECT_ID = "OTEL_EXPORTER_GCP_LOGGING_PROJECT_ID" +""" +.. envvar:: OTEL_EXPORTER_GCP_LOGGING_PROJECT_ID + + GCP project ID for the exporter to send logs to. Equivalent to constructor parameter to + :class:`opentelemetry.exporter.cloud_logging.CloudLoggingExporter`. +""" diff --git a/opentelemetry-exporter-gcp-logging/src/opentelemetry/exporter/cloud_logging/version.py b/opentelemetry-exporter-gcp-logging/src/opentelemetry/exporter/cloud_logging/version.py new file mode 100644 index 00000000..bb01a21c --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/src/opentelemetry/exporter/cloud_logging/version.py @@ -0,0 +1,15 @@ +# Copyright 2025 The OpenTelemetry Authors +# +# 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. + +__version__ = "1.9.0.dev0" diff --git a/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_non_json_dict_bytes.json b/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_non_json_dict_bytes.json new file mode 100644 index 00000000..b658c503 --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_non_json_dict_bytes.json @@ -0,0 +1,20 @@ +[ + { + "entries": [ + { + "logName": "projects/fakeproject/logs/test", + "resource": { + "labels": { + "location": "global", + "namespace": "", + "node_id": "" + }, + "type": "generic_node" + }, + "textPayload": "MTIz", + "timestamp": "2025-01-15T21:25:10.997977393Z" + } + ], + "partialSuccess": true + } +] diff --git a/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_otlp_dict_body.json b/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_otlp_dict_body.json new file mode 100644 index 00000000..30a9ac22 --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_otlp_dict_body.json @@ -0,0 +1,39 @@ +[ + { + "entries": [ + { + "jsonPayload": { + "kvlistValue": { + "values": [ + { + "key": "content", + "value": { + "stringValue": "You're a helpful assistant." + } + } + ] + } + }, + "labels": { + "event.name": "gen_ai.system.message", + "gen_ai.system": "true", + "test": "23" + }, + "logName": "projects/fakeproject/logs/test", + "resource": { + "labels": { + "location": "global", + "namespace": "", + "node_id": "" + }, + "type": "generic_node" + }, + "severity": "ERROR", + "spanId": "0000000000000016", + "timestamp": "2025-01-15T21:25:10.997977393Z", + "trace": "projects/fakeproject/traces/00000000000000000000000000000019" + } + ], + "partialSuccess": true + } +] diff --git a/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_otlp_various_different_types_in_attrs_and_bytes_body.json b/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_otlp_various_different_types_in_attrs_and_bytes_body.json new file mode 100644 index 00000000..bbf8dfcc --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_otlp_various_different_types_in_attrs_and_bytes_body.json @@ -0,0 +1,36 @@ +[ + { + "entries": [ + { + "jsonPayload": { + "Classe": [ + "Email addresses", + "Passwords" + ], + "CreationDate": "2012-05-05", + "Date": "2016-05-21T21:35:40Z", + "Link": "http://some_link.com", + "LogoType": "png", + "Ref": 164611595.0 + }, + "labels": { + "boolArray": "[true, false, true, true]", + "float": "25.43231", + "int": "25", + "intArray": "[21, 18, 23, 17]" + }, + "logName": "projects/fakeproject/logs/test", + "resource": { + "labels": { + "location": "global", + "namespace": "", + "node_id": "" + }, + "type": "generic_node" + }, + "timestamp": "2025-01-15T21:25:10.997977393Z" + } + ], + "partialSuccess": true + } +] diff --git a/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_various_types_of_bodies[bool].json b/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_various_types_of_bodies[bool].json new file mode 100644 index 00000000..e0bc44ff --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_various_types_of_bodies[bool].json @@ -0,0 +1,20 @@ +[ + { + "entries": [ + { + "logName": "projects/fakeproject/logs/test", + "resource": { + "labels": { + "location": "global", + "namespace": "", + "node_id": "" + }, + "type": "generic_node" + }, + "textPayload": "true", + "timestamp": "2025-01-15T21:25:10.997977393Z" + } + ], + "partialSuccess": true + } +] diff --git a/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_various_types_of_bodies[str].json b/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_various_types_of_bodies[str].json new file mode 100644 index 00000000..939d4784 --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/tests/__snapshots__/test_cloud_logging/test_convert_various_types_of_bodies[str].json @@ -0,0 +1,20 @@ +[ + { + "entries": [ + { + "logName": "projects/fakeproject/logs/test", + "resource": { + "labels": { + "location": "global", + "namespace": "", + "node_id": "" + }, + "type": "generic_node" + }, + "textPayload": "A text body", + "timestamp": "2025-01-15T21:25:10.997977393Z" + } + ], + "partialSuccess": true + } +] diff --git a/opentelemetry-exporter-gcp-logging/tests/conftest.py b/opentelemetry-exporter-gcp-logging/tests/conftest.py new file mode 100644 index 00000000..a23c6dbc --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/tests/conftest.py @@ -0,0 +1,19 @@ +# Copyright 2025 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 +# +# https://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. + +# pylint: disable=unused-import + +# import fixtures to be made available to other tests +from fixtures.cloud_logging_fake import fixture_cloudloggingfake +from fixtures.snapshot_logging_calls import fixture_snapshot_writelogentrycalls diff --git a/opentelemetry-exporter-gcp-logging/tests/fixtures/cloud_logging_fake.py b/opentelemetry-exporter-gcp-logging/tests/fixtures/cloud_logging_fake.py new file mode 100644 index 00000000..7ecf64f9 --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/tests/fixtures/cloud_logging_fake.py @@ -0,0 +1,127 @@ +# Copyright 2025 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 +# +# https://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. +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from functools import partial +from typing import Callable, Iterable, List, cast +from unittest.mock import patch + +import grpc +import pytest +from google.cloud.logging_v2.services.logging_service_v2.transports.grpc import ( + LoggingServiceV2GrpcTransport, +) +from google.cloud.logging_v2.types.logging import WriteLogEntriesRequest + +# pylint: disable=no-name-in-module +from google.protobuf.empty_pb2 import Empty +from grpc import ( + GenericRpcHandler, + ServicerContext, + insecure_channel, + method_handlers_generic_handler, + unary_unary_rpc_method_handler, +) +from opentelemetry.exporter.cloud_logging import CloudLoggingExporter + + +@dataclass +class WriteLogEntriesCall: + write_log_entries_request: WriteLogEntriesRequest + user_agent: str + + +PROJECT_ID = "fakeproject" + + +class FakeWriteLogEntriesHandler(GenericRpcHandler): + """gRPC handler captures request protos made to Cloud Logging's WriteLogEntries.""" + + _service = "google.logging.v2.LoggingServiceV2" + _method = "WriteLogEntries" + + def __init__(self): + # pylint: disable=no-member + super().__init__() + self._calls: List[WriteLogEntriesCall] = [] + + def write_log_entries_request_handler( + req: WriteLogEntriesRequest, context: ServicerContext + ) -> Empty: + metadata_dict = dict(context.invocation_metadata()) + self._calls.append( + WriteLogEntriesCall( + write_log_entries_request=req, + user_agent=cast(str, metadata_dict["user-agent"]), + ) + ) + return Empty() + + self._wrapped = method_handlers_generic_handler( + self._service, + { + self._method: unary_unary_rpc_method_handler( + write_log_entries_request_handler, + WriteLogEntriesRequest.deserialize, + Empty.SerializeToString, + ) + }, + ) + + def service(self, handler_call_details): + res = self._wrapped.service(handler_call_details) + return res + + def get_calls(self) -> List[WriteLogEntriesCall]: + """Returns calls made to WriteLogEntries""" + return self._calls + + +@dataclass +class CloudLoggingFake: + exporter: CloudLoggingExporter + get_calls: Callable[[], List[WriteLogEntriesCall]] + + +@pytest.fixture(name="cloudloggingfake") +def fixture_cloudloggingfake() -> Iterable[CloudLoggingFake]: + """Fixture providing faked Cloud Logging api with captured requests""" + + handler = FakeWriteLogEntriesHandler() + server = None + + try: + # Run in a single thread to serialize requests + with ThreadPoolExecutor(1) as executor: + server = grpc.server(executor, handlers=[handler]) + port = server.add_insecure_port("localhost:0") + server.start() + + # patch LoggingServiceV2Transport.create_channel staticmethod to return an insecure + # channel but otherwise respect any parameters passed to it + with patch.object( + LoggingServiceV2GrpcTransport, + "create_channel", + partial(insecure_channel, target=f"localhost:{port}"), + ): + yield CloudLoggingFake( + exporter=CloudLoggingExporter( + project_id=PROJECT_ID, + default_log_name="test", + ), + get_calls=handler.get_calls, + ) + finally: + if server: + server.stop(None) diff --git a/opentelemetry-exporter-gcp-logging/tests/fixtures/snapshot_logging_calls.py b/opentelemetry-exporter-gcp-logging/tests/fixtures/snapshot_logging_calls.py new file mode 100644 index 00000000..eb6dd44d --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/tests/fixtures/snapshot_logging_calls.py @@ -0,0 +1,54 @@ +# Copyright 2025 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 +# +# https://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. + +from typing import List, Optional, cast + +import pytest +from fixtures.cloud_logging_fake import WriteLogEntriesCall +from google.protobuf import json_format +from syrupy.extensions.json import JSONSnapshotExtension +from syrupy.types import ( + PropertyFilter, + PropertyMatcher, + SerializableData, + SerializedData, +) + + +# pylint: disable=too-many-ancestors +class WriteLogEntryCallSnapshotExtension(JSONSnapshotExtension): + """syrupy extension to serialize WriteLogEntriesRequest to JSON for storing as a snapshot.""" + + def serialize( + self, + data: SerializableData, + *, + exclude: Optional[PropertyFilter] = None, + matcher: Optional[PropertyMatcher] = None, + ) -> SerializedData: + json = [ + json_format.MessageToDict( + type(call.write_log_entries_request).pb( + call.write_log_entries_request + ) + ) + for call in cast(List[WriteLogEntriesCall], data) + ] + return super().serialize(json, exclude=exclude, matcher=matcher) + + +@pytest.fixture(name="snapshot_writelogentrycalls") +def fixture_snapshot_writelogentrycalls(snapshot): + """Fixture for snapshot testing of WriteLogEntriesCalls""" + return snapshot.use_extension(WriteLogEntryCallSnapshotExtension)() diff --git a/opentelemetry-exporter-gcp-logging/tests/test_cloud_logging.py b/opentelemetry-exporter-gcp-logging/tests/test_cloud_logging.py new file mode 100644 index 00000000..da1012e4 --- /dev/null +++ b/opentelemetry-exporter-gcp-logging/tests/test_cloud_logging.py @@ -0,0 +1,173 @@ +# Copyright 2025 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 +# +# https://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. +""" +Some tests in this file use [syrupy](https://github.com/tophat/syrupy) for snapshot testing aka +golden testing. The Cloud Logging API calls are captured with a gRPC fake and compared to the existing +snapshot file in the __snapshots__ directory. + +If an expected behavior change is made to the exporter causing these tests to fail, regenerate +the snapshots by running tox to pass the --snapshot-update flag to pytest: + +```sh +tox -e py310-ci-test-cloudlogging -- --snapshot-update +``` + +Be sure to review the changes. +""" +import re +from typing import List, Union + +import pytest +from fixtures.cloud_logging_fake import CloudLoggingFake, WriteLogEntriesCall +from google.auth.credentials import AnonymousCredentials +from google.cloud.logging_v2.services.logging_service_v2 import ( + LoggingServiceV2Client, +) +from opentelemetry._logs.severity import SeverityNumber +from opentelemetry.exporter.cloud_logging import CloudLoggingExporter +from opentelemetry.sdk._logs import LogData +from opentelemetry.sdk._logs._internal import LogRecord +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope + +PROJECT_ID = "fakeproject" + + +def test_too_large_log_raises_warning(caplog) -> None: + client = LoggingServiceV2Client(credentials=AnonymousCredentials()) + no_default_logname = CloudLoggingExporter( + project_id=PROJECT_ID, client=client + ) + no_default_logname.export( + [ + LogData( + log_record=LogRecord( + body="abc", + resource=Resource({}), + attributes={str(i): "i" * 10000 for i in range(1000)}, + ), + instrumentation_scope=InstrumentationScope("test"), + ) + ] + ) + assert len(caplog.records) == 1 + assert ( + "exceeds Cloud Logging's maximum limit of 256000 bytes" in caplog.text + ) + + +def test_convert_otlp_dict_body( + cloudloggingfake: CloudLoggingFake, + snapshot_writelogentrycalls: List[WriteLogEntriesCall], +) -> None: + log_data = [ + LogData( + log_record=LogRecord( + timestamp=1736976310997977393, + severity_number=SeverityNumber(20), + trace_id=25, + span_id=22, + attributes={ + "gen_ai.system": True, + "test": 23, + "event.name": "gen_ai.system.message", + }, + body={ + "kvlistValue": { + "values": [ + { + "key": "content", + "value": { + "stringValue": "You're a helpful assistant." + }, + } + ] + } + }, + ), + instrumentation_scope=InstrumentationScope("test"), + ) + ] + cloudloggingfake.exporter.export(log_data) + assert cloudloggingfake.get_calls() == snapshot_writelogentrycalls + for call in cloudloggingfake.get_calls(): + assert ( + re.match( + r"^opentelemetry-python \S+; google-cloud-logging-exporter \S+ grpc-python/\S+", + call.user_agent, + ) + is not None + ) + + +def test_convert_otlp_various_different_types_in_attrs_and_bytes_body( + cloudloggingfake: CloudLoggingFake, + snapshot_writelogentrycalls: List[WriteLogEntriesCall], +) -> None: + log_data = [ + LogData( + log_record=LogRecord( + timestamp=1736976310997977393, + attributes={ + "int": 25, + "float": 25.43231, + "intArray": [21, 18, 23, 17], + "boolArray": [True, False, True, True], + }, + body=b'{"Date": "2016-05-21T21:35:40Z", "CreationDate": "2012-05-05", "LogoType": "png", "Ref": 164611595, "Classe": ["Email addresses", "Passwords"],"Link":"http://some_link.com"}', + ), + instrumentation_scope=InstrumentationScope("test"), + ) + ] + cloudloggingfake.exporter.export(log_data) + assert cloudloggingfake.get_calls() == snapshot_writelogentrycalls + + +def test_convert_non_json_dict_bytes( + cloudloggingfake: CloudLoggingFake, + snapshot_writelogentrycalls: List[WriteLogEntriesCall], +) -> None: + log_data = [ + LogData( + log_record=LogRecord( + timestamp=1736976310997977393, + body=b"123", + ), + instrumentation_scope=InstrumentationScope("test"), + ) + ] + cloudloggingfake.exporter.export(log_data) + assert cloudloggingfake.get_calls() == snapshot_writelogentrycalls + + +@pytest.mark.parametrize( + "body", + [pytest.param("A text body", id="str"), pytest.param(True, id="bool")], +) +def test_convert_various_types_of_bodies( + cloudloggingfake: CloudLoggingFake, + snapshot_writelogentrycalls: List[WriteLogEntriesCall], + body: Union[str, bool], +) -> None: + log_data = [ + LogData( + log_record=LogRecord( + timestamp=1736976310997977393, + body=body, + ), + instrumentation_scope=InstrumentationScope("test"), + ) + ] + cloudloggingfake.exporter.export(log_data) + assert cloudloggingfake.get_calls() == snapshot_writelogentrycalls diff --git a/release.py b/release.py index 1d5b8417..db87abc9 100755 --- a/release.py +++ b/release.py @@ -54,6 +54,7 @@ ALTERNATE_SUFFIXES = { # Mark monitoring and resource detector alpha "opentelemetry-exporter-gcp-monitoring": "a0", + "opentelemetry-exporter-gcp-logging": "a0", "opentelemetry-resourcedetector-gcp": "a0", } diff --git a/tox.ini b/tox.ini index 34d1c740..e5192ce6 100644 --- a/tox.ini +++ b/tox.ini @@ -5,13 +5,13 @@ requires = tox>=4 envlist = ; Add the `ci` factor to any env that should be running during CI. - py3{7,8,9,10,11,12,13}-ci-test-{cloudtrace,cloudmonitoring,propagator,resourcedetector} - {lint,mypy}-ci-{cloudtrace,cloudmonitoring,propagator,resourcedetector} + py3{7,8,9,10,11,12,13}-ci-test-{cloudtrace,cloudmonitoring,propagator,resourcedetector, cloudlogging} + {lint,mypy}-ci-{cloudtrace,cloudmonitoring,propagator,resourcedetector, cloudlogging} docs-ci ; These are development commands and share the same virtualenv within each ; package root directory - {fix}-{cloudtrace,cloudmonitoring,propagator,resourcedetector} + {fix}-{cloudtrace,cloudmonitoring,propagator,resourcedetector, cloudlogging} ; Installs dev depenedencies and all packages in this repo with editable ; install into a single env. Useful for editor autocompletion @@ -27,6 +27,7 @@ base_deps = monorepo_deps = cloudmonitoring: -e {toxinidir}/opentelemetry-resourcedetector-gcp/ cloudtrace: -e {toxinidir}/opentelemetry-resourcedetector-gcp/ + cloudlogging: -e {toxinidir}/opentelemetry-resourcedetector-gcp/ dev_basepython = python3.10 dev_deps = @@ -55,10 +56,11 @@ setenv = ; for package specific commands, use these envvars to cd into the directory cloudtrace: PACKAGE_NAME = opentelemetry-exporter-gcp-trace cloudmonitoring: PACKAGE_NAME = opentelemetry-exporter-gcp-monitoring + cloudlogging: PACKAGE_NAME = opentelemetry-exporter-gcp-logging propagator: PACKAGE_NAME = opentelemetry-propagator-gcp resourcedetector: PACKAGE_NAME = opentelemetry-resourcedetector-gcp -[testenv:py3{7,8,9,10,11,12,13}-ci-test-{cloudtrace,cloudmonitoring,propagator,resourcedetector}] +[testenv:py3{7,8,9,10,11,12,13}-ci-test-{cloudtrace,cloudmonitoring,propagator,resourcedetector, cloudlogging}] deps = ; editable install the package itself -e {toxinidir}/{env:PACKAGE_NAME} @@ -79,7 +81,7 @@ allowlist_externals = bash {toxinidir}/get_mock_server.sh -[testenv:{lint,mypy}-ci-{cloudtrace,cloudmonitoring,propagator,resourcedetector}] +[testenv:{lint,mypy}-ci-{cloudtrace,cloudmonitoring,propagator,resourcedetector, cloudlogging}] basepython = {[constants]dev_basepython} deps = ; editable install the package itself @@ -107,13 +109,14 @@ allowlist_externals = make bash -[testenv:{fix}-{cloudtrace,cloudmonitoring,propagator,resourcedetector}] +[testenv:{fix}-{cloudtrace,cloudmonitoring,propagator,resourcedetector, cloudlogging}] basepython = {[constants]dev_basepython} envdir = cloudtrace: opentelemetry-exporter-gcp-trace/venv cloudmonitoring: opentelemetry-exporter-gcp-monitoring/venv propagator: opentelemetry-propagator-gcp/venv resourcedetector: opentelemetry-resourcedetector-gcp/venv + cloudlogging: opentelemetry-exporter-gcp-logging/venv deps = {[constants]dev_deps} {[constants]monorepo_deps} @@ -134,3 +137,4 @@ deps = -e opentelemetry-exporter-gcp-trace -e opentelemetry-propagator-gcp -e opentelemetry-resourcedetector-gcp + -e opentelemetry-exporter-gcp-logging