From a2808f73c75f93c235976e29e9f802672099b375 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Fri, 30 Aug 2024 16:10:33 -0700 Subject: [PATCH 01/14] get TrackedRequest in handler emit --- pyproject.toml | 1 + scout_apm_python_logging/handler.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index b82db3f..734fb9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ python = "^3.12" opentelemetry-api = "^1.26.0" opentelemetry-sdk = "^1.26.0" opentelemetry-exporter-otlp = "^1.26.0" +scout-apm = "^3.1.0" [tool.poetry.group.dev.dependencies] diff --git a/scout_apm_python_logging/handler.py b/scout_apm_python_logging/handler.py index 5b32077..1a16dc4 100644 --- a/scout_apm_python_logging/handler.py +++ b/scout_apm_python_logging/handler.py @@ -5,6 +5,7 @@ from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler from opentelemetry.sdk._logs.export import BatchLogRecordProcessor from opentelemetry.sdk.resources import Resource +from scout_apm.core.tracked_request import TrackedRequest class OtelScoutHandler(logging.Handler): @@ -41,6 +42,26 @@ def setup_logging(self): def emit(self, record): print("Emitting log") + scout_request = TrackedRequest.instance() + if scout_request: + # Add Scout-specific attributes to the log record + record.scout_request_id = scout_request.request_id + record.scout_start_time = scout_request.start_time.isoformat() + record.scout_end_time = scout_request.end_time.isoformat() if scout_request.end_time else None + + # Add duration if the request is completed + if scout_request.end_time: + record.scout_duration = (scout_request.end_time - scout_request.start_time).total_seconds() + + # Add tags + for key, value in scout_request.tags.items(): + setattr(record, f'scout_tag_{key}', value) + + # Add the current span's operation if available + current_span = scout_request.current_span() + if current_span: + record.scout_current_operation = current_span.operation + self.otel_handler.emit(record) def close(self): From 1ba857fed7493b5a8fff235ca7eb6b9b9ba76209 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Fri, 30 Aug 2024 18:31:33 -0700 Subject: [PATCH 02/14] add handling_log threading.local() value --- scout_apm_python_logging/handler.py | 69 ++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/scout_apm_python_logging/handler.py b/scout_apm_python_logging/handler.py index 1a16dc4..945b89b 100644 --- a/scout_apm_python_logging/handler.py +++ b/scout_apm_python_logging/handler.py @@ -1,11 +1,13 @@ import logging import os +import threading from opentelemetry import _logs from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler from opentelemetry.sdk._logs.export import BatchLogRecordProcessor from opentelemetry.sdk.resources import Resource from scout_apm.core.tracked_request import TrackedRequest +from scout_apm.core import scout_config class OtelScoutHandler(logging.Handler): @@ -16,6 +18,7 @@ def __init__(self, service_name): self.ingest_key = self._get_ingest_key() self.endpoint = self._get_endpoint() self.setup_logging() + self._handling_log = threading.local() def setup_logging(self): self.logger_provider = LoggerProvider( @@ -42,35 +45,57 @@ def setup_logging(self): def emit(self, record): print("Emitting log") - scout_request = TrackedRequest.instance() - if scout_request: - # Add Scout-specific attributes to the log record - record.scout_request_id = scout_request.request_id - record.scout_start_time = scout_request.start_time.isoformat() - record.scout_end_time = scout_request.end_time.isoformat() if scout_request.end_time else None - - # Add duration if the request is completed - if scout_request.end_time: - record.scout_duration = (scout_request.end_time - scout_request.start_time).total_seconds() - - # Add tags - for key, value in scout_request.tags.items(): - setattr(record, f'scout_tag_{key}', value) - - # Add the current span's operation if available - current_span = scout_request.current_span() - if current_span: - record.scout_current_operation = current_span.operation - - self.otel_handler.emit(record) + if getattr(self._handling_log, 'value', False): + # We're already handling a log message, so don't try to get the TrackedRequest + return self.otel_handler.emit(record) + + try: + self._handling_log.value = True + scout_request = TrackedRequest.instance() + if scout_request: + # Add Scout-specific attributes to the log record + record.scout_request_id = scout_request.request_id + record.scout_start_time = scout_request.start_time.isoformat() + if scout_request.end_time: + record.scout_end_time = scout_request.end_time.isoformat() + + # Add duration if the request is completed + if scout_request.end_time: + record.scout_duration = ( + scout_request.end_time - scout_request.start_time).total_seconds() + + # Add tags + for key, value in scout_request.tags.items(): + setattr(record, f'scout_tag_{key}', value) + + # Add the current span's operation if available + current_span = scout_request.current_span() + if current_span: + record.scout_current_operation = current_span.operation + + self.otel_handler.emit(record) + finally: + self._handling_log.value = False def close(self): if self.logger_provider: self.logger_provider.shutdown() super().close() + def _get_service_name(self, provided_name): + if provided_name: + return provided_name + + # Try to get the name from Scout APM config + scout_name = scout_config.value('name') + if scout_name: + return scout_name + + # Fallback to a default name if neither is available + return "unnamed-service" # These getters will be replaced by a config module to read these values # from a config file or environment variables as the Scout APM agent does. + def _get_endpoint(self): return os.getenv( "SCOUT_LOGS_REPORTING_ENDPOINT", "otlp.scoutotel.com:4317" @@ -80,4 +105,4 @@ def _get_ingest_key(self): ingest_key = os.getenv("SCOUT_LOGS_INGEST_KEY") if not ingest_key: raise ValueError("SCOUT_LOGS_INGEST_KEY is not set") - return ingest_key \ No newline at end of file + return ingest_key From 44ac724aad150184fe8de77f4a61bab39acb1397 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Fri, 30 Aug 2024 18:32:20 -0700 Subject: [PATCH 03/14] handle encoding enums and ignore exporter logs --- scout_apm_python_logging/handler.py | 32 ++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/scout_apm_python_logging/handler.py b/scout_apm_python_logging/handler.py index 945b89b..0a033b8 100644 --- a/scout_apm_python_logging/handler.py +++ b/scout_apm_python_logging/handler.py @@ -1,6 +1,7 @@ import logging import os import threading +import enum from opentelemetry import _logs from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler @@ -44,7 +45,9 @@ def setup_logging(self): ) def emit(self, record): - print("Emitting log") + if self.should_ignore_log(record): + return + if getattr(self._handling_log, 'value', False): # We're already handling a log message, so don't try to get the TrackedRequest return self.otel_handler.emit(record) @@ -52,6 +55,7 @@ def emit(self, record): try: self._handling_log.value = True scout_request = TrackedRequest.instance() + if scout_request: # Add Scout-specific attributes to the log record record.scout_request_id = scout_request.request_id @@ -73,10 +77,36 @@ def emit(self, record): if current_span: record.scout_current_operation = current_span.operation + # Handle Enum values in the log record + self.handle_enums(record) + self.otel_handler.emit(record) + except Exception as e: + print(f"Error in OtelScoutHandler.emit: {e}") finally: self._handling_log.value = False + def handle_enums(self, record): + if isinstance(record.msg, enum.EnumMeta): + print("found an enum class") + for attr, value in record.__dict__.items(): + print(f"attr: {attr}, value: {value} type {type(value)}") + record.msg = f"Enum class: {record.msg.__name__}" + elif isinstance(record.msg, enum.Enum): + record.msg = f"{record.msg.__class__.__name__}.{record.msg.name}" + + for attr, value in record.__dict__.items(): + if isinstance(value, enum.EnumMeta): + setattr(record, attr, f"Enum class: {value.__name__}") + elif isinstance(value, enum.Enum): + setattr(record, attr, f"{value.__class__.__name__}.{value.name}") + + def should_ignore_log(self, record): + # Ignore logs from the OpenTelemetry exporter + if record.name.startswith('opentelemetry.exporter.otlp'): + return True + + return False def close(self): if self.logger_provider: self.logger_provider.shutdown() From 8eafae24c6158e876b84c5610dc6a67e267fde0b Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Tue, 3 Sep 2024 15:02:20 -0700 Subject: [PATCH 04/14] add flask example app --- examples/flask_app.py | 71 +++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 2 files changed, 72 insertions(+) create mode 100644 examples/flask_app.py diff --git a/examples/flask_app.py b/examples/flask_app.py new file mode 100644 index 0000000..533e1e0 --- /dev/null +++ b/examples/flask_app.py @@ -0,0 +1,71 @@ +import os +from flask import Flask, request +import logging +from logging.config import dictConfig +from scout_apm.flask import ScoutApm +from scout_apm_python_logging import OtelScoutHandler + +# Logging configuration +LOGGING_CONFIG = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" + }, + "simple": {"format": "%(levelname)s %(message)s"}, + }, + "handlers": { + "otel": { + "level": "DEBUG", + "class": "scout_apm_python_logging.OtelScoutHandler", + "service_name": "example-python-app", + }, + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "simple", + }, + }, + "loggers": { + "": { # Root logger + "handlers": ["console", "otel"], + "level": "DEBUG", + }, + }, +} + +# Apply the logging configuration +dictConfig(LOGGING_CONFIG) + +# Create Flask app +app = Flask(__name__) + +# Scout APM configuration +app.config["SCOUT_NAME"] = "Example Python App" +app.config["SCOUT_KEY"] = os.environ.get("SCOUT_KEY") # Make sure to set this environment variable + +# Initialize Scout APM +ScoutApm(app) + +# Get a logger +logger = logging.getLogger(__name__) + +@app.route('/') +def hello(): + logger.info("Received request for hello endpoint") + return "Hello, World!" + +@app.route('/error') +def error(): + logger.error("This is a test error") + return "Error logged", 500 + +@app.route('/debug') +def debug(): + logger.debug("This is a debug message") + return "Debug message logged", 200 + +if __name__ == '__main__': + logger.info("Starting Flask application") + app.run(debug=True) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 734fb9b..6b56708 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ pytest-cov = "^5.0.0" flake8 = "^7.1.1" flake8-black = "^0.3.6" mypy = "^1.11.1" +flask = "^3.0.3" [tool.taskipy.tasks] test = "pytest . --cov-report=term-missing --cov=." From 23ff2353ef536ccc8ddad8892f27a34432bdfb9c Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Tue, 3 Sep 2024 15:04:37 -0700 Subject: [PATCH 05/14] format and lint --- examples/flask_app.py | 18 ++++++++++++------ scout_apm_python_logging/handler.py | 19 ++++++++++--------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/examples/flask_app.py b/examples/flask_app.py index 533e1e0..6bc4498 100644 --- a/examples/flask_app.py +++ b/examples/flask_app.py @@ -43,7 +43,9 @@ # Scout APM configuration app.config["SCOUT_NAME"] = "Example Python App" -app.config["SCOUT_KEY"] = os.environ.get("SCOUT_KEY") # Make sure to set this environment variable +app.config["SCOUT_KEY"] = os.environ.get( + "SCOUT_KEY" +) # Make sure to set this environment variable # Initialize Scout APM ScoutApm(app) @@ -51,21 +53,25 @@ # Get a logger logger = logging.getLogger(__name__) -@app.route('/') + +@app.route("/") def hello(): logger.info("Received request for hello endpoint") return "Hello, World!" -@app.route('/error') + +@app.route("/error") def error(): logger.error("This is a test error") return "Error logged", 500 -@app.route('/debug') + +@app.route("/debug") def debug(): logger.debug("This is a debug message") return "Debug message logged", 200 -if __name__ == '__main__': + +if __name__ == "__main__": logger.info("Starting Flask application") - app.run(debug=True) \ No newline at end of file + app.run(debug=True) diff --git a/scout_apm_python_logging/handler.py b/scout_apm_python_logging/handler.py index 0a033b8..c3555ef 100644 --- a/scout_apm_python_logging/handler.py +++ b/scout_apm_python_logging/handler.py @@ -48,7 +48,7 @@ def emit(self, record): if self.should_ignore_log(record): return - if getattr(self._handling_log, 'value', False): + if getattr(self._handling_log, "value", False): # We're already handling a log message, so don't try to get the TrackedRequest return self.otel_handler.emit(record) @@ -66,11 +66,12 @@ def emit(self, record): # Add duration if the request is completed if scout_request.end_time: record.scout_duration = ( - scout_request.end_time - scout_request.start_time).total_seconds() + scout_request.end_time - scout_request.start_time + ).total_seconds() # Add tags for key, value in scout_request.tags.items(): - setattr(record, f'scout_tag_{key}', value) + setattr(record, f"scout_tag_{key}", value) # Add the current span's operation if available current_span = scout_request.current_span() @@ -103,10 +104,11 @@ def handle_enums(self, record): def should_ignore_log(self, record): # Ignore logs from the OpenTelemetry exporter - if record.name.startswith('opentelemetry.exporter.otlp'): + if record.name.startswith("opentelemetry.exporter.otlp"): return True - + return False + def close(self): if self.logger_provider: self.logger_provider.shutdown() @@ -117,19 +119,18 @@ def _get_service_name(self, provided_name): return provided_name # Try to get the name from Scout APM config - scout_name = scout_config.value('name') + scout_name = scout_config.value("name") if scout_name: return scout_name # Fallback to a default name if neither is available return "unnamed-service" + # These getters will be replaced by a config module to read these values # from a config file or environment variables as the Scout APM agent does. def _get_endpoint(self): - return os.getenv( - "SCOUT_LOGS_REPORTING_ENDPOINT", "otlp.scoutotel.com:4317" - ) + return os.getenv("SCOUT_LOGS_REPORTING_ENDPOINT", "otlp.scoutotel.com:4317") def _get_ingest_key(self): ingest_key = os.getenv("SCOUT_LOGS_INGEST_KEY") From 91992a66010a7d916e0052bbaea3c921fe948394 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Tue, 3 Sep 2024 17:23:22 -0700 Subject: [PATCH 06/14] remove unnecessary enum handler and ignore methods. clean up example flask_app update gitignore --- .gitignore | 6 +++++ examples/flask_app.py | 3 +-- scout_apm_python_logging/handler.py | 37 ++++------------------------- 3 files changed, 12 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index 54aed5b..a35bf7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Python __pycache__/ +.DS_Store # generated log files *.log @@ -43,3 +44,8 @@ cover/ # mypy .mypy_cache/ +# vscode +settings.json + +# env +.env* \ No newline at end of file diff --git a/examples/flask_app.py b/examples/flask_app.py index 6bc4498..391e4a4 100644 --- a/examples/flask_app.py +++ b/examples/flask_app.py @@ -1,9 +1,8 @@ import os -from flask import Flask, request +from flask import Flask import logging from logging.config import dictConfig from scout_apm.flask import ScoutApm -from scout_apm_python_logging import OtelScoutHandler # Logging configuration LOGGING_CONFIG = { diff --git a/scout_apm_python_logging/handler.py b/scout_apm_python_logging/handler.py index c3555ef..ccd784a 100644 --- a/scout_apm_python_logging/handler.py +++ b/scout_apm_python_logging/handler.py @@ -1,7 +1,6 @@ import logging import os import threading -import enum from opentelemetry import _logs from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler @@ -45,9 +44,6 @@ def setup_logging(self): ) def emit(self, record): - if self.should_ignore_log(record): - return - if getattr(self._handling_log, "value", False): # We're already handling a log message, so don't try to get the TrackedRequest return self.otel_handler.emit(record) @@ -78,37 +74,12 @@ def emit(self, record): if current_span: record.scout_current_operation = current_span.operation - # Handle Enum values in the log record - self.handle_enums(record) - self.otel_handler.emit(record) except Exception as e: print(f"Error in OtelScoutHandler.emit: {e}") finally: self._handling_log.value = False - def handle_enums(self, record): - if isinstance(record.msg, enum.EnumMeta): - print("found an enum class") - for attr, value in record.__dict__.items(): - print(f"attr: {attr}, value: {value} type {type(value)}") - record.msg = f"Enum class: {record.msg.__name__}" - elif isinstance(record.msg, enum.Enum): - record.msg = f"{record.msg.__class__.__name__}.{record.msg.name}" - - for attr, value in record.__dict__.items(): - if isinstance(value, enum.EnumMeta): - setattr(record, attr, f"Enum class: {value.__name__}") - elif isinstance(value, enum.Enum): - setattr(record, attr, f"{value.__class__.__name__}.{value.name}") - - def should_ignore_log(self, record): - # Ignore logs from the OpenTelemetry exporter - if record.name.startswith("opentelemetry.exporter.otlp"): - return True - - return False - def close(self): if self.logger_provider: self.logger_provider.shutdown() @@ -128,12 +99,14 @@ def _get_service_name(self, provided_name): # These getters will be replaced by a config module to read these values # from a config file or environment variables as the Scout APM agent does. - def _get_endpoint(self): - return os.getenv("SCOUT_LOGS_REPORTING_ENDPOINT", "otlp.scoutotel.com:4317") + return ( + scout_config.value("SCOUT_LOGS_REPORTING_ENDPOINT") + or "otlp.scoutotel.com:4317" + ) def _get_ingest_key(self): - ingest_key = os.getenv("SCOUT_LOGS_INGEST_KEY") + ingest_key = scout_config.value("SCOUT_LOGS_INGEST_KEY") if not ingest_key: raise ValueError("SCOUT_LOGS_INGEST_KEY is not set") return ingest_key From 41d8436af93b544d970a18c80e856599faa050af Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Tue, 3 Sep 2024 17:32:02 -0700 Subject: [PATCH 07/14] use _get_service_name in handler init --- scout_apm_python_logging/handler.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scout_apm_python_logging/handler.py b/scout_apm_python_logging/handler.py index ccd784a..d15e8bf 100644 --- a/scout_apm_python_logging/handler.py +++ b/scout_apm_python_logging/handler.py @@ -13,8 +13,8 @@ class OtelScoutHandler(logging.Handler): def __init__(self, service_name): super().__init__() - self.service_name = service_name self.logger_provider = None + self.service_name = self._get_service_name(service_name) self.ingest_key = self._get_ingest_key() self.endpoint = self._get_endpoint() self.setup_logging() @@ -85,6 +85,9 @@ def close(self): self.logger_provider.shutdown() super().close() + # These getters will be replaced by a config module to read these values + # from a config file or environment variables as the Scout APM agent does. + def _get_service_name(self, provided_name): if provided_name: return provided_name @@ -94,11 +97,8 @@ def _get_service_name(self, provided_name): if scout_name: return scout_name - # Fallback to a default name if neither is available return "unnamed-service" - # These getters will be replaced by a config module to read these values - # from a config file or environment variables as the Scout APM agent does. def _get_endpoint(self): return ( scout_config.value("SCOUT_LOGS_REPORTING_ENDPOINT") From ad7140b017b9c3cbef46adacc6632cad0cbfc1d5 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Wed, 4 Sep 2024 11:44:51 -0700 Subject: [PATCH 08/14] correctly get values from scout_config --- scout_apm_python_logging/handler.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scout_apm_python_logging/handler.py b/scout_apm_python_logging/handler.py index d15e8bf..7052bf3 100644 --- a/scout_apm_python_logging/handler.py +++ b/scout_apm_python_logging/handler.py @@ -45,7 +45,7 @@ def setup_logging(self): def emit(self, record): if getattr(self._handling_log, "value", False): - # We're already handling a log message, so don't try to get the TrackedRequest + # We're already handling a log message, don't try to get the TrackedRequest return self.otel_handler.emit(record) try: @@ -65,6 +65,8 @@ def emit(self, record): scout_request.end_time - scout_request.start_time ).total_seconds() + record.service_name = self.service_name + # Add tags for key, value in scout_request.tags.items(): setattr(record, f"scout_tag_{key}", value) @@ -101,12 +103,11 @@ def _get_service_name(self, provided_name): def _get_endpoint(self): return ( - scout_config.value("SCOUT_LOGS_REPORTING_ENDPOINT") - or "otlp.scoutotel.com:4317" + scout_config.value("logs_reporting_endpoint") or "otlp.scoutotel.com:4317" ) def _get_ingest_key(self): - ingest_key = scout_config.value("SCOUT_LOGS_INGEST_KEY") + ingest_key = scout_config.value("logs_ingest_key") if not ingest_key: raise ValueError("SCOUT_LOGS_INGEST_KEY is not set") return ingest_key From c472dd25cda90ebdd5d97570581e8afe2f8b289d Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Wed, 4 Sep 2024 11:45:57 -0700 Subject: [PATCH 09/14] add handler test --- tests/unit/test_handler.py | 155 +++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 tests/unit/test_handler.py diff --git a/tests/unit/test_handler.py b/tests/unit/test_handler.py new file mode 100644 index 0000000..13cfb70 --- /dev/null +++ b/tests/unit/test_handler.py @@ -0,0 +1,155 @@ +import pytest +import logging +import io +from unittest.mock import patch, MagicMock +from scout_apm_python_logging.handler import OtelScoutHandler + + +@pytest.fixture +def otel_scout_handler(): + with patch("scout_apm_python_logging.handler.OTLPLogExporter"), patch( + "scout_apm_python_logging.handler.LoggerProvider" + ), patch("scout_apm_python_logging.handler.BatchLogRecordProcessor"), patch( + "scout_apm_python_logging.handler.Resource" + ): + handler = OtelScoutHandler(service_name="test-service") + yield handler + + +def test_init(otel_scout_handler): + assert otel_scout_handler.service_name == "test-service" + assert otel_scout_handler.ingest_key is not None + assert otel_scout_handler.endpoint is not None + + +@patch("scout_apm_python_logging.handler.TrackedRequest") +def test_emit_with_scout_request(mock_tracked_request, otel_scout_handler): + mock_request = MagicMock() + mock_request.request_id = "test-id" + mock_request.start_time.isoformat.return_value = "2024-03-06T12:00:00" + mock_request.end_time.isoformat.return_value = "2024-03-06T12:00:01" + mock_request.tags = {"key": "value"} + mock_tracked_request.instance.return_value = mock_request + + with patch.object(otel_scout_handler, "otel_handler") as mock_otel_handler: + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="Test message", + args=(), + exc_info=None, + ) + otel_scout_handler.emit(record) + + mock_otel_handler.emit.assert_called_once() + assert record.scout_request_id == "test-id" + assert record.scout_start_time == "2024-03-06T12:00:00" + assert record.scout_end_time == "2024-03-06T12:00:01" + assert record.scout_tag_key == "value" + + +@patch("scout_apm_python_logging.handler.TrackedRequest") +def test_emit_without_scout_request(mock_tracked_request, otel_scout_handler): + mock_tracked_request.instance.return_value = None + with patch.object(otel_scout_handler, "otel_handler") as mock_otel_handler: + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="Test message", + args=(), + exc_info=None, + ) + otel_scout_handler.emit(record) + + mock_otel_handler.emit.assert_called_once() + assert not hasattr(record, "scout_request_id") + + +def test_emit_already_handling_log(otel_scout_handler): + with patch.object(otel_scout_handler, "otel_handler") as mock_otel_handler: + # Simulate that we're already handling a log message + otel_scout_handler._handling_log.value = True + + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="Test message", + args=(), + exc_info=None, + ) + otel_scout_handler.emit(record) + + mock_otel_handler.emit.assert_called_once_with(record) + + otel_scout_handler._handling_log.value = False + + +def test_emit_exception_handling(otel_scout_handler): + with patch( + "scout_apm_python_logging.handler.TrackedRequest" + ) as mock_tracked_request, patch( + "sys.stdout", new_callable=io.StringIO + ) as mock_stdout: + + mock_tracked_request.instance.side_effect = Exception("Test exception") + + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="Test message", + args=(), + exc_info=None, + ) + + otel_scout_handler.emit(record) + + # Check that the exception was caught and the error message was printed + assert ( + "Error in OtelScoutHandler.emit: Test exception" in mock_stdout.getvalue() + ) + + otel_scout_handler._handling_log.value = False + + +def test_close(otel_scout_handler): + with patch.object(otel_scout_handler.logger_provider, "shutdown") as mock_shutdown: + otel_scout_handler.close() + mock_shutdown.assert_called_once() + + +@patch("scout_apm_python_logging.handler.scout_config") +def test_get_service_name(mock_scout_config, otel_scout_handler): + mock_scout_config.value.return_value = "scout-service" + assert otel_scout_handler._get_service_name(None) == "scout-service" + assert ( + otel_scout_handler._get_service_name("provided-service") == "provided-service" + ) + mock_scout_config.value.return_value = None + assert otel_scout_handler._get_service_name(None) == "unnamed-service" + + +@patch("scout_apm_python_logging.handler.scout_config") +def test_get_endpoint(mock_scout_config, otel_scout_handler): + mock_scout_config.value.return_value = "custom.endpoint:1234" + assert otel_scout_handler._get_endpoint() == "custom.endpoint:1234" + + +@patch("scout_apm_python_logging.handler.scout_config") +def test_get_ingest_key(mock_scout_config, otel_scout_handler): + mock_scout_config.value.return_value = "test-ingest-key" + assert otel_scout_handler._get_ingest_key() == "test-ingest-key" + + +@patch("scout_apm_python_logging.handler.scout_config") +def test_get_ingest_key_not_set(mock_scout_config, otel_scout_handler): + mock_scout_config.value.return_value = None + with pytest.raises(ValueError, match="SCOUT_LOGS_INGEST_KEY is not set"): + otel_scout_handler._get_ingest_key() From 1efe2215ea25677fc81da640d08bce3ed118b0e4 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Wed, 4 Sep 2024 14:46:32 -0700 Subject: [PATCH 10/14] add Test and Lint workflow --- .github/workflows/main.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..6677c76 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,35 @@ +name: Test and Lint + +on: + pull_request: + +jobs: + test-and-lint: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install poetry + poetry install + + - name: Run linting + run: | + poetry run task lint + + - name: Run type checking + run: | + poetry run task mypy + + - name: Run tests with coverage + run: | + poetry run task test \ No newline at end of file From 11bc24253df464a9b5e26e71d8e80006c61c4379 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Wed, 4 Sep 2024 20:17:42 -0700 Subject: [PATCH 11/14] set supported python version to ^3.9 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6b56708..9caf609 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = ["Quinn Milionis "] readme = "README.md" [tool.poetry.dependencies] -python = "^3.12" +python = "^3.9" opentelemetry-api = "^1.26.0" opentelemetry-sdk = "^1.26.0" opentelemetry-exporter-otlp = "^1.26.0" From a1ec8c67170ea367f5697b2a4e2a458aeb8c7ead Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Thu, 5 Sep 2024 10:12:24 -0700 Subject: [PATCH 12/14] noqa line length in format string --- examples/dictConfig.py | 6 +++--- examples/flask_app.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/dictConfig.py b/examples/dictConfig.py index fbeb105..32bd4cb 100644 --- a/examples/dictConfig.py +++ b/examples/dictConfig.py @@ -1,5 +1,5 @@ -# An example of how to use the scout handler with dictConfig, -# useful for early testing +# An example of how to use the scout handler with dictConfig, +# useful for early testing import logging import logging.config @@ -10,7 +10,7 @@ "disable_existing_loggers": False, "formatters": { "verbose": { - "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" # noqa E501 }, "simple": {"format": "%(levelname)s %(message)s"}, }, diff --git a/examples/flask_app.py b/examples/flask_app.py index 391e4a4..8b7b9c3 100644 --- a/examples/flask_app.py +++ b/examples/flask_app.py @@ -10,7 +10,7 @@ "disable_existing_loggers": False, "formatters": { "verbose": { - "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" # noqa E501 }, "simple": {"format": "%(levelname)s %(message)s"}, }, From 33131ce19f0abe20d59f990661053f94e77af5e0 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Thu, 5 Sep 2024 10:36:16 -0700 Subject: [PATCH 13/14] add test ingest key to workflow --- .github/workflows/main.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6677c76..21f7114 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,7 +9,8 @@ jobs: strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12"] - + env: + SCOUT_LOGS_INGEST_KEY: test-ingest-key steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} From 4da23549f3ef1b5ea840b1a68fd92288ed3523f3 Mon Sep 17 00:00:00 2001 From: Quinn Milionis Date: Thu, 5 Sep 2024 17:11:43 -0700 Subject: [PATCH 14/14] support getting ingest key from django --- scout_apm_python_logging/handler.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scout_apm_python_logging/handler.py b/scout_apm_python_logging/handler.py index 7052bf3..0f80ea5 100644 --- a/scout_apm_python_logging/handler.py +++ b/scout_apm_python_logging/handler.py @@ -109,5 +109,17 @@ def _get_endpoint(self): def _get_ingest_key(self): ingest_key = scout_config.value("logs_ingest_key") if not ingest_key: - raise ValueError("SCOUT_LOGS_INGEST_KEY is not set") + try: + from django.conf import settings + + ingest_key = getattr(settings, "SCOUT_LOGS_INGEST_KEY", None) + except ImportError: + pass + + if not ingest_key: + raise ValueError( + "SCOUT_LOGS_INGEST_KEY is not set, please do so in \ + your environment or django config file" + ) + return ingest_key