Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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"]
env:
SCOUT_LOGS_INGEST_KEY: test-ingest-key
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Python
__pycache__/
.DS_Store

# generated log files
*.log
Expand Down Expand Up @@ -43,3 +44,8 @@ cover/
# mypy
.mypy_cache/

# vscode
settings.json

# env
.env*
6 changes: 3 additions & 3 deletions examples/dictConfig.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"},
},
Expand Down
76 changes: 76 additions & 0 deletions examples/flask_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import os
from flask import Flask
import logging
from logging.config import dictConfig
from scout_apm.flask import ScoutApm

# 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" # noqa E501
},
"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)
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ authors = ["Quinn Milionis <[email protected]>"]
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"
scout-apm = "^3.1.0"


[tool.poetry.group.dev.dependencies]
Expand All @@ -21,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=."
Expand Down
79 changes: 71 additions & 8 deletions scout_apm_python_logging/handler.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
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):
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()
self._handling_log = threading.local()

def setup_logging(self):
self.logger_provider = LoggerProvider(
Expand All @@ -40,8 +44,43 @@ def setup_logging(self):
)

def emit(self, record):
print("Emitting log")
self.otel_handler.emit(record)
if getattr(self._handling_log, "value", False):
# We're already handling a log message, 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()

record.service_name = self.service_name

# 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)
except Exception as e:
print(f"Error in OtelScoutHandler.emit: {e}")
finally:
self._handling_log.value = False

def close(self):
if self.logger_provider:
Expand All @@ -50,13 +89,37 @@ def close(self):

# 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

# Try to get the name from Scout APM config
scout_name = scout_config.value("name")
if scout_name:
return scout_name

return "unnamed-service"

def _get_endpoint(self):
return os.getenv(
"SCOUT_LOGS_REPORTING_ENDPOINT", "otlp.scoutotel.com:4317"
return (
scout_config.value("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("logs_ingest_key")
if not ingest_key:
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")
return ingest_key
raise ValueError(
"SCOUT_LOGS_INGEST_KEY is not set, please do so in \
your environment or django config file"
)

return ingest_key
Loading