diff --git a/examples/handlers/opentelemetry_structlog/README.md b/examples/handlers/opentelemetry_structlog/README.md new file mode 100644 index 0000000000..ec9fb98424 --- /dev/null +++ b/examples/handlers/opentelemetry_structlog/README.md @@ -0,0 +1,156 @@ +# OpenTelemetry Python `structlog` Handler Example with Docker +This is a demo for the custom structlog handler implemented for OpenTelemetry. Overall, this example runs a basic Flask application with Docker to demonstrate an example application that uses OpenTelemetry logging with Python's logging library structlog. This example is scalable to other software systems that require the use of the structlog library for logging. + +Note: This example is adapted from OpenTelemetry's [Getting Started Tutorial for Python](https://opentelemetry.io/docs/languages/python/getting-started/) guide and OpenTelemetry's [example for logs](https://github.com/open-telemetry/opentelemetry-python/blob/main/docs/examples/logs/README.rst) code. + +## Prerequisites +Python 3 + +## Installation +Prior to building the example application, set up the directory and virtual environment: +``` +mkdir otel-structlog-example +cd otel-structlog-example +python3 -m venv venv +source ./venv/bin/activate +``` + +After activating the virtual environment `venv`, install flask and structlog. +``` +pip install flask +pip install structlog +pip install opentelemetry-exporter-otlp +``` + +### Create and Launch HTTP Server +Now that the environment is set up, create an `app.py` flask application. This is a basic example that uses the structlog Python logging library for OpenTelemetry logging instead of the standard Python logging library. + +Notice the importance of the following imports for using the structlog handler: `import structlog` and `from handlers.opentelemetry_structlog.src.exporter import StructlogHandler`. + +``` +from random import randint +from flask import Flask, request +import structlog +import sys +sys.path.insert(0, '../../..') +from handlers.opentelemetry_structlog.src.exporter import StructlogHandler +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( + OTLPLogExporter, +) +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.resources import Resource +from opentelemetry._logs import set_logger_provider +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor +from opentelemetry.sdk.resources import Resource + +logger_provider = LoggerProvider( + resource=Resource.create( + { + "service.name": "shoppingcart", + "service.instance.id": "instance-12", + } + ), +) +set_logger_provider(logger_provider) + +# Replace the standard logging configuration with structlog +structlog_handler = StructlogHandler(service_name="flask-structlog-demo", server_hostname="instance-1", exporter=OTLPLogExporter(insecure=True)) +structlog_handler._logger_provider = logger_provider +structlog_logger = structlog.wrap_logger(structlog.get_logger(), processors=[structlog_handler]) # Add StructlogHandler to the logger + +app = Flask(__name__) + +@app.route("/rolldice") +def roll_dice(): + player = request.args.get('player', default=None, type=str) + result = str(roll()) + if player: + structlog_logger.warning("Player %s is rolling the dice: %s", player, result, level="warning") + else: + structlog_logger.warning("Anonymous player is rolling the dice: %s", result, level="warning") + return result + + +def roll(): + return randint(1, 6) +``` + +Run the application on port 8080 with the following flask command and open [http://localhost:8080/rolldice](http://localhost:8080/rolldice) in your web browser to ensure it is working. + +``` +flask run -p 8080 +``` + +However, do not be alarmed if you receive these errors since Docker is not yet set up to export the logs: +``` +Transient error StatusCode.UNAVAILABLE encountered while exporting logs to localhost:4317, retrying in 1s. +Transient error StatusCode.UNAVAILABLE encountered while exporting logs to localhost:4317, retrying in 2s. +Transient error StatusCode.UNAVAILABLE encountered while exporting logs to localhost:4317, retrying in 4s. +... +``` + +## Run with Docker + +To serve the application on Docker, first create the `otel-collector-config.yaml` file locally in the application's repository. +``` +# otel-collector-config.yaml +receivers: + otlp: + protocols: + grpc: + +processors: + batch: + +exporters: + logging: + verbosity: detailed + +service: + pipelines: + logs: + receivers: [otlp] + processors: [batch] + exporters: [logging] +``` + +Next, start the Docker container: +``` +docker run \ + -p 4317:4317 \ + -v $(pwd)/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml \ + otel/opentelemetry-collector-contrib:latest +``` + +And lastly, run the basic application with flask: +``` +flask run -p 8080 +``` + +Here is some example output: +``` + * Debug mode: off +WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on http://127.0.0.1:8080 +Press CTRL+C to quit +2024-04-28 23:15:22 [warning ] Anonymous player is rolling the dice: 1 +127.0.0.1 - - [28/Apr/2024 23:15:22] "GET /rolldice HTTP/1.1" 200 - +2024-04-28 23:15:27 [warning ] Anonymous player is rolling the dice: 6 +127.0.0.1 - - [28/Apr/2024 23:15:27] "GET /rolldice HTTP/1.1" 200 - +2024-04-28 23:15:28 [warning ] Anonymous player is rolling the dice: 3 +127.0.0.1 - - [28/Apr/2024 23:15:28] "GET /rolldice HTTP/1.1" 200 - +2024-04-28 23:15:29 [warning ] Anonymous player is rolling the dice: 4 +127.0.0.1 - - [28/Apr/2024 23:15:29] "GET /rolldice HTTP/1.1" 200 - +2024-04-28 23:15:29 [warning ] Anonymous player is rolling the dice: 1 +127.0.0.1 - - [28/Apr/2024 23:15:29] "GET /rolldice HTTP/1.1" 200 - +2024-04-28 23:15:30 [warning ] Anonymous player is rolling the dice: 2 +127.0.0.1 - - [28/Apr/2024 23:15:30] "GET /rolldice HTTP/1.1" 200 - +2024-04-28 23:15:31 [warning ] Anonymous player is rolling the dice: 3 +127.0.0.1 - - [28/Apr/2024 23:15:31] "GET /rolldice HTTP/1.1" 200 - +2024-04-28 23:16:14 [warning ] Anonymous player is rolling the dice: 4 +127.0.0.1 - - [28/Apr/2024 23:16:14] "GET /rolldice HTTP/1.1" 200 - +``` + + +## Contributors +Caroline Gilbert: [carolincgilbert](https://github.com/carolinecgilbert) diff --git a/examples/handlers/opentelemetry_structlog/app.py b/examples/handlers/opentelemetry_structlog/app.py new file mode 100644 index 0000000000..54fd1f4d0e --- /dev/null +++ b/examples/handlers/opentelemetry_structlog/app.py @@ -0,0 +1,60 @@ +import sys +from random import randint + +import structlog +from flask import Flask, request + +sys.path.insert(0, "../../..") +from handlers.opentelemetry_structlog.src.exporter import StructlogHandler +from opentelemetry._logs import set_logger_provider +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( + OTLPLogExporter, +) +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.resources import Resource + +logger_provider = LoggerProvider( + resource=Resource.create( + { + "service.name": "shoppingcart", + "service.instance.id": "instance-12", + } + ), +) +set_logger_provider(logger_provider) + +# Replace the standard logging configuration with structlog +structlog_handler = StructlogHandler( + service_name="flask-structlog-demo", + server_hostname="instance-1", + exporter=OTLPLogExporter(insecure=True), +) +structlog_handler._logger_provider = logger_provider +structlog_logger = structlog.wrap_logger( + structlog.get_logger(), processors=[structlog_handler] +) # Add StructlogHandler to the logger + + +app = Flask(__name__) + + +@app.route("/rolldice") +def roll_dice(): + player = request.args.get("player", default=None, type=str) + result = str(roll()) + if player: + structlog_logger.warning( + "Player %s is rolling the dice: %s", + player, + result, + level="warning", + ) + else: + structlog_logger.warning( + "Anonymous player is rolling the dice: %s", result, level="warning" + ) + return result + + +def roll(): + return randint(1, 6) diff --git a/examples/handlers/opentelemetry_structlog/otel-collector-config.yaml b/examples/handlers/opentelemetry_structlog/otel-collector-config.yaml new file mode 100644 index 0000000000..5525cdd849 --- /dev/null +++ b/examples/handlers/opentelemetry_structlog/otel-collector-config.yaml @@ -0,0 +1,19 @@ +# otel-collector-config.yaml +receivers: + otlp: + protocols: + grpc: + +processors: + batch: + +exporters: + logging: + verbosity: detailed + +service: + pipelines: + logs: + receivers: [otlp] + processors: [batch] + exporters: [logging] \ No newline at end of file diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/handlers/opentelemetry_structlog/README.md b/handlers/opentelemetry_structlog/README.md new file mode 100644 index 0000000000..ac5652f1d3 --- /dev/null +++ b/handlers/opentelemetry_structlog/README.md @@ -0,0 +1 @@ +# Structlog handler for OpenTelemetry diff --git a/handlers/opentelemetry_structlog/__init__.py b/handlers/opentelemetry_structlog/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/handlers/opentelemetry_structlog/pyproject.toml b/handlers/opentelemetry_structlog/pyproject.toml new file mode 100644 index 0000000000..241c46dfed --- /dev/null +++ b/handlers/opentelemetry_structlog/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = [ + "hatchling", +] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-structlog" +dynamic = [ + "version", +] +description = "Structlog handler for emitting logs to OpenTelemetry" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.7" +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", +] +dependencies = [ + "opentelemetry-sdk ~= 1.22", + "structlog ~= 24.1", +] + +[tool.hatch.version] +path = "src/opentelemetry-structlog/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", +] + +[tool.hatch.build.targets.wheel] +packages = [ + "src/opentelemetry-structlog", +] diff --git a/handlers/opentelemetry_structlog/src/README.md b/handlers/opentelemetry_structlog/src/README.md new file mode 100644 index 0000000000..a61fef5e81 --- /dev/null +++ b/handlers/opentelemetry_structlog/src/README.md @@ -0,0 +1,60 @@ +# Structlog Handler for OpenTelemetry +This project provides a Structlog handler for OpenTelemetry applications. The handler converts Structlog logs into the OpenTelemetry Logs Protocol (OTLP) format for export to a collector. + +## Usage + +To use the Structlog handler in your OpenTelemetry application, follow these steps: + +1. Import the necessary modules: + +```python +import structlog +from opentelemetry.sdk._logs._internal.export import LogExporter +from opentelemetry.sdk.resources import Resource +from handlers.opentelemetry_structlog.src.exporter import StructlogHandler +``` + +2. Initialize the StructlogHandler with your service name, server hostname, and LogExporter instance: + +```python +service_name = "my_service" +server_hostname = "my_server" +exporter = LogExporter() # Initialize your LogExporter instance +handler = StructlogHandler(service_name, server_hostname, exporter) +``` + +3. Add the handler to your Structlog logger: + +```python +structlog.configure( + processors=[structlog.processors.JSONRenderer()], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + context_class=dict, + **handler.wrap_for_structlog(), +) +``` + +4. Use the logger as usual with Structlog: + +```python +logger = structlog.get_logger() +logger.info("This is a test log message.") +``` +## OpenTelemetry Application Example with Handler +See the structlog handler demo in the examples directory of this repository for a step-by-step guide on using the handler in an OpenTelemetry application. + +## Customization + +The StructlogHandler supports customization through its constructor parameters: + +- `service_name`: The name of your service. +- `server_hostname`: The hostname of the server where the logs originate. +- `exporter`: An instance of your LogExporter for exporting logs to a collector. + +## Notes + +- This handler automatically converts the `timestamp` key in the `event_dict` to ISO 8601 format for better compatibility. +- It performs operations similar to `structlog.processors.ExceptionRenderer`, so avoid using `ExceptionRenderer` in the same pipeline. +``` diff --git a/handlers/opentelemetry_structlog/src/__init__.py b/handlers/opentelemetry_structlog/src/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/handlers/opentelemetry_structlog/src/exporter.py b/handlers/opentelemetry_structlog/src/exporter.py new file mode 100644 index 0000000000..9c83b13a23 --- /dev/null +++ b/handlers/opentelemetry_structlog/src/exporter.py @@ -0,0 +1,171 @@ +"""OpenTelemetry processor for structlog.""" + +import traceback +from datetime import datetime, timezone +from typing import Dict + +import structlog +from structlog._frames import _format_exception +from structlog._log_levels import NAME_TO_LEVEL +from structlog.processors import _figure_out_exc_info + +from opentelemetry._logs import std_to_otel +from opentelemetry.sdk._logs._internal import LoggerProvider, LogRecord +from opentelemetry.sdk._logs._internal.export import ( + BatchLogRecordProcessor, + LogExporter, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.semconv.trace import SpanAttributes +from opentelemetry.trace import get_current_span + +_EXCLUDE_ATTRS = {"exception", "timestamp"} + + +class StructlogHandler: + """A structlog processor that writes logs in OTLP format to a collector. + + Note: this will replace (or insert if not present) the `timestamp` key in the + `event_dict` to be in an ISO 8601 format that is more widely recognized. This + means that `structlog.processors.TimeStamper` is not required to be added to the + processors list if this processor is used. + + Note: this also performs the operations done by + `structlog.processors.ExceptionRenderer`. DO NOT use `ExceptionRenderer` in the + same processor pipeline as this processor. + """ + + # this was largely inspired by the OpenTelemetry handler for stdlib `logging`: + # https://github.com/open-telemetry/opentelemetry-python/blob/8f312c49a5c140c14d1829c66abfe4e859ad8fd7/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py#L318 + + def __init__( + self, + service_name: str, + server_hostname: str, + exporter: LogExporter, + ) -> None: + logger_provider = LoggerProvider( + resource=Resource.create( + { + "service.name": service_name, + "service.instance.id": server_hostname, + } + ), + ) + + logger_provider.add_log_record_processor( + BatchLogRecordProcessor(exporter, max_export_batch_size=1) + ) + + self._logger_provider = logger_provider + self._logger = logger_provider.get_logger(__name__) + + def _pre_process( + self, event_dict: structlog.typing.EventDict + ) -> structlog.typing.EventDict: + event_dict["timestamp"] = datetime.now(timezone.utc) + + self._pre_process_exc_info(event_dict) + + return event_dict + + def _post_process( + self, event_dict: structlog.typing.EventDict + ) -> structlog.typing.EventDict: + event_dict["timestamp"] = event_dict["timestamp"].isoformat() + + self._post_process_exc_info(event_dict) + + return event_dict + + def _pre_process_exc_info( + self, event_dict: structlog.typing.EventDict + ) -> structlog.typing.EventDict: + exc_info = event_dict.pop("exc_info", None) + if exc_info is not None: + event_dict["exception"] = _figure_out_exc_info(exc_info) + + return event_dict + + def _post_process_exc_info( + self, event_dict: structlog.typing.EventDict + ) -> structlog.typing.EventDict: + exception = event_dict.pop("exception", None) + if exception is not None: + event_dict["exception"] = _format_exception(exception) + + return event_dict + + def _translate( + self, + timestamp: int, + extra_attrs: Dict[str, str], + event_dict: structlog.typing.EventDict, + ) -> LogRecord: + span_context = get_current_span().get_span_context() + # attributes = self._get_attributes(record) + severity_number = std_to_otel(NAME_TO_LEVEL[event_dict["level"]]) + + return LogRecord( + timestamp=timestamp, + trace_id=span_context.trace_id, + span_id=span_context.span_id, + trace_flags=span_context.trace_flags, + severity_text=event_dict["level"], + severity_number=severity_number, + body=event_dict["event"], + resource=self._logger.resource, + attributes={ + **{ + k: v + for k, v in event_dict.items() + if k not in _EXCLUDE_ATTRS + }, + **extra_attrs, + }, + ) + + @staticmethod + def _parse_timestamp(event_dict: structlog.typing.EventDict) -> int: + return int(event_dict["timestamp"].timestamp() * 1e9) + + @staticmethod + def _parse_exception( + event_dict: structlog.typing.EventDict, + ) -> Dict[str, str]: + # taken from: https://github.com/open-telemetry/opentelemetry-python/blob/c4d17e9f14f3cafb6757b96eefabdc7ed4891306/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py#L458-L475 + attributes: Dict[str, str] = {} + exception = event_dict.get("exception", None) + if exception is not None: + exc_type = "" + message = "" + stack_trace = "" + exctype, value, tb = exception + if exctype is not None: + exc_type = exctype.__name__ + if value is not None and value.args: + message = value.args[0] + if tb is not None: + # https://github.com/open-telemetry/opentelemetry-specification/blob/9fa7c656b26647b27e485a6af7e38dc716eba98a/specification/trace/semantic_conventions/exceptions.md#stacktrace-representation + stack_trace = "".join(traceback.format_exception(*exception)) + attributes[SpanAttributes.EXCEPTION_TYPE] = exc_type + attributes[SpanAttributes.EXCEPTION_MESSAGE] = message + attributes[SpanAttributes.EXCEPTION_STACKTRACE] = stack_trace + + return attributes + + def __call__( + self, + logger: structlog.typing.WrappedLogger, + name: str, + event_dict: structlog.typing.EventDict, + ): + """Emit a record.""" + event_dict = self._pre_process(event_dict) + timestamp = self._parse_timestamp(event_dict) + extra_attrs = self._parse_exception(event_dict) + event_dict = self._post_process(event_dict) + + self._logger.emit(self._translate(timestamp, extra_attrs, event_dict)) + + return event_dict diff --git a/handlers/opentelemetry_structlog/src/version.py b/handlers/opentelemetry_structlog/src/version.py new file mode 100644 index 0000000000..3dc1f76bc6 --- /dev/null +++ b/handlers/opentelemetry_structlog/src/version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/instrumentation/opentelemetry-instrumentation-logging/test-requirements.txt b/instrumentation/opentelemetry-instrumentation-logging/test-requirements.txt index 600d066cc1..3bb8ed5deb 100644 --- a/instrumentation/opentelemetry-instrumentation-logging/test-requirements.txt +++ b/instrumentation/opentelemetry-instrumentation-logging/test-requirements.txt @@ -10,5 +10,7 @@ tomli==2.0.1 typing_extensions==4.12.2 wrapt==1.16.0 zipp==3.19.2 +structlog==24.1.0 +opentelemetry-exporter-otlp==1.27.0 -e opentelemetry-instrumentation -e instrumentation/opentelemetry-instrumentation-logging diff --git a/instrumentation/opentelemetry-instrumentation-logging/tests/test_logging.py b/instrumentation/opentelemetry-instrumentation-logging/tests/test_logging.py index c8b8744cf3..fd8297a638 100644 --- a/instrumentation/opentelemetry-instrumentation-logging/tests/test_logging.py +++ b/instrumentation/opentelemetry-instrumentation-logging/tests/test_logging.py @@ -13,11 +13,19 @@ # limitations under the License. import logging +from datetime import datetime, timezone from typing import Optional from unittest import mock +# Imports for StructlogHandler tests +from unittest.mock import MagicMock, Mock, patch + import pytest +from handlers.opentelemetry_structlog.src.exporter import ( + LogExporter, + StructlogHandler, +) from opentelemetry.instrumentation.logging import ( # pylint: disable=no-name-in-module DEFAULT_LOGGING_FORMAT, LoggingInstrumentor, @@ -222,3 +230,213 @@ def test_no_op_tracer_provider(self): self.assertEqual(record.otelTraceID, "0") self.assertEqual(record.otelServiceName, "") self.assertEqual(record.otelTraceSampled, False) + + +# StructlogHandler Tests +# Test Initialization +class TestStructlogHandler(TestBase): + @pytest.fixture(autouse=True) + def inject_fixtures(self, caplog): + self.caplog = caplog # pylint: disable=attribute-defined-outside-init + + def mocker(self): + return MagicMock() + + def setUp(self): + super().setUp() + LoggingInstrumentor().instrument() + self.tracer = get_tracer(__name__) + + def tearDown(self): + super().tearDown() + LoggingInstrumentor().uninstrument() + + def structlog_exporter(self): + with self.caplog.at_level(level=logging.INFO): + # Mock the LogExporter dependency + mock_exporter = Mock(spec=LogExporter) + # Instantiate the StructlogHandler with mock dependencies + exporter = StructlogHandler( + "test_service", "test_host", mock_exporter + ) + return exporter + + def test_initialization(self): + exporter = self.structlog_exporter() + assert ( + exporter._logger_provider is not None + ), "LoggerProvider should be initialized" + assert exporter._logger is not None, "Logger should be initialized" + + def test_pre_process_adds_timestamp(self): + event_dict = {"event": "test_event"} + processed_event = self.structlog_exporter()._pre_process(event_dict) + assert ( + "timestamp" in processed_event + ), "Timestamp should be added in pre-processing" + + def test_post_process_formats_timestamp(self): + # Assuming the pre_process method has added a datetime object + event_dict = {"timestamp": datetime.now(timezone.utc)} + processed_event = self.structlog_exporter()._post_process(event_dict) + assert isinstance( + processed_event["timestamp"], str + ), "Timestamp should be formatted to string in ISO format" + + def test_parse_exception(self): + # Mocking an exception event + exception = (ValueError, ValueError("mock error"), None) + event_dict = {"exception": exception} + parsed_exception = self.structlog_exporter()._parse_exception( + event_dict + ) + assert ( + parsed_exception["exception.type"] == "ValueError" + ), "Exception type should be parsed" + assert ( + parsed_exception["exception.message"] == "mock error" + ), "Exception message should be parsed" + + def test_parse_timestamp(self): + # Assuming a specific datetime for consistency + fixed_datetime = datetime(2020, 1, 1, tzinfo=timezone.utc) + event_dict = {"timestamp": fixed_datetime} + timestamp = self.structlog_exporter()._parse_timestamp(event_dict) + expected_timestamp = ( + 1577836800000000000 # Expected nanoseconds since epoch + ) + assert ( + timestamp == expected_timestamp + ), "Timestamp should be correctly parsed to nanoseconds" + + def test_call_method_processes_log_correctly(self): + # Mock the logger and exporter + exporter = MagicMock() + logger = MagicMock() + exporter_instance = StructlogHandler( + "test_service", "test_host", exporter + ) + exporter_instance._logger = logger + + # Define an event dictionary + event_dict = { + "level": "info", + "event": "test event", + "timestamp": datetime.now(timezone.utc), + } + + # Call the __call__ method of StructlogHandler + _ = exporter_instance(logger=None, name=None, event_dict=event_dict) + + # Assert that the logger's emit method was called with the processed event + logger.emit.assert_called_once() + + def test_log_record_translation_attributes(self): + """Verify that event_dict translates correctly into a LogRecord with the correct attributes.""" + exporter = MagicMock() + logger = MagicMock() + exporter_instance = StructlogHandler( + "test_service", "test_host", exporter + ) + exporter_instance._logger = logger + + timestamp = datetime.now(timezone.utc).isoformat() + event_dict = { + "level": "info", + "event": "test event", + "timestamp": timestamp, + } + # Get the StructlogHandler instance + + # Assuming StructlogHandler has a method to process and possibly log the event_dict directly. + # Use the instance to process the event_dict. + # Mocking the internal logger's emit method to capture the log record + with patch.object(exporter_instance._logger, "emit") as mock_emit: + exporter_instance(event_dict=event_dict, logger=logger, name=None) + calls = mock_emit.call_args_list + assert len(calls) > 0, "Emit should be called" + log_record = calls[0][0][0] # First call, first arg + + # Assuming log_record is the structured log that would have been emitted, + # and you need to verify its contents. + # Need to adjust the assertion depending on how log records are structured. + # I am assuming log_record is a dictionary that was passed to logger.emit. + assert ( + log_record.body == event_dict["event"] + ), "LogRecord body should match event" + + assert ( + log_record.attributes["level"] == event_dict["level"] + ), "LogRecord level should match event" + + def test_filtering_of_excluded_attributes(self): + """Ensure specified attributes are not passed to the log record.""" + event_dict = { + "level": "error", + "event": "Something happened!", + "timestamp": datetime.now(timezone.utc), + "exception": (ValueError, ValueError("An error occurred"), None), + } + + # Get the StructlogHandler instance + exporter_instance = self.structlog_exporter() + + with patch.object(exporter_instance._logger, "emit") as mocked_emit: + # Call the exporter_instance with the event_dict + exporter_instance(event_dict=event_dict, logger=None, name=None) + + # Check if emit method was called + mocked_emit.assert_called_once() + + # Get the log record passed to emit method + log_record = mocked_emit.call_args.args[0] + + # Check if the exception attribute is not present in the log record + assert ( + "exception" not in log_record.attributes + ), "Excluded attributes should not be in the log record" + + def test_trace_context_propogation(self): + """Ensure trace context is correctly propagated to the log record.""" + with self.tracer.start_as_current_span("test_span") as span: + span_id = format(span.get_span_context().span_id, "016x") + trace_id = format(span.get_span_context().trace_id, "032x") + trace_sampled = span.get_span_context().trace_flags.sampled + event_dict = { + "level": "info", + "event": "test event", + "timestamp": datetime.now(timezone.utc), + } + + # Get the StructlogHandler instance + exporter_instance = self.structlog_exporter() + + with patch.object(exporter_instance, "_logger") as mocked_logger: + exporter_instance( + event_dict=event_dict, logger=None, name=None + ) + calls = mocked_logger.emit.call_args_list + log_record = calls[0][0][0] + + # Assert that the log record has the correct trace context + actual_span_id = format(log_record.span_id, "016x") + assert ( + actual_span_id == span_id + ), "Span ID should be propagated" + + actual_trace_id = format(log_record.trace_id, "032x") + assert ( + actual_trace_id == trace_id + ), "Trace ID should be propagated" + + assert ( + log_record.trace_flags == trace_sampled + ), "Trace flags should be propagated" + + +class TimestampRecord: + def __init__(self, data): + self.timestam = data + + def timestamp(self): + return self.timestam diff --git a/tox.ini b/tox.ini index 5bf05d451f..1ee7e81fc9 100644 --- a/tox.ini +++ b/tox.ini @@ -396,6 +396,7 @@ setenv = ; i.e: CORE_REPO_SHA=dde62cebffe519c35875af6d06fae053b3be65ec tox -e CORE_REPO_SHA={env:CORE_REPO_SHA:main} CORE_REPO=git+https://github.com/open-telemetry/opentelemetry-python.git@{env:CORE_REPO_SHA} + PYTHONPATH={toxinidir} commands_pre = opentelemetry-instrumentation: pip install opentelemetry-api@{env:CORE_REPO}\#egg=opentelemetry-api&subdirectory=opentelemetry-api