From 5c45734d42261540b7be072a50b285ab6636347a Mon Sep 17 00:00:00 2001 From: Harvey Chui Date: Fri, 27 Feb 2026 16:09:56 +1100 Subject: [PATCH 1/2] [FDE-360] Add stdlib JSON log formatter and configure_logging to pylogtracer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add JSONLogFormatter: stdlib-only structured JSON formatter matching Go zap logger schema (timestamp, level, msg, caller, logger, stacktrace) - Add configure_logging(): one-call root logger setup with dev/prod dual mode via DEVELOPMENT_MODE env var, langfuse suppression, log level control - Make loguru and opentelemetry optional extras — core has zero dependencies - Fix pyproject.toml packages config and lower requires-python to >=3.11 - Extract OpenTelemetry init into tracing_setup.py, lazy-load via __getattr__ - Use single-arg traceback.format_exception() (Python 3.14 compatible) - Add 21 tests (formatter + config) and CI workflow + Makefile targets Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci-python.yml | 4 + Makefile | 3 + pylogtracer/README.md | 114 +++++++++++++-------- pylogtracer/pylogtracer/__init__.py | 116 ++++++--------------- pylogtracer/pylogtracer/config.py | 61 +++++++++++ pylogtracer/pylogtracer/formatter.py | 91 +++++++++++++++++ pylogtracer/pylogtracer/tracing_setup.py | 99 ++++++++++++++++++ pylogtracer/tests/__init__.py | 0 pylogtracer/tests/test_config.py | 82 +++++++++++++++ pylogtracer/tests/test_formatter.py | 125 +++++++++++++++++++++++ pyproject.toml | 18 ++-- uv.lock | 51 +++++++-- 12 files changed, 625 insertions(+), 139 deletions(-) create mode 100644 pylogtracer/pylogtracer/config.py create mode 100644 pylogtracer/pylogtracer/formatter.py create mode 100644 pylogtracer/pylogtracer/tracing_setup.py create mode 100644 pylogtracer/tests/__init__.py create mode 100644 pylogtracer/tests/test_config.py create mode 100644 pylogtracer/tests/test_formatter.py diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml index bf84f0d..866380a 100755 --- a/.github/workflows/ci-python.yml +++ b/.github/workflows/ci-python.yml @@ -24,3 +24,7 @@ jobs: run: | . .venv/bin/activate make lint-python + - name: Run tests + run: | + . .venv/bin/activate + make test-python diff --git a/Makefile b/Makefile index 0bff4d1..eee3d02 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,9 @@ lint-go: fi golangci-lint run ./... +test-python: + pytest pylogtracer/tests/ -v + lint-python: ruff format pylogtracer --check ruff check pylogtracer diff --git a/pylogtracer/README.md b/pylogtracer/README.md index 077d5f9..cdf4330 100644 --- a/pylogtracer/README.md +++ b/pylogtracer/README.md @@ -1,65 +1,97 @@ -# PyLogTracer - Structured Logging and Tracing for Python +# PyLogTracer -## Overview -PyLogTracer is a Python logging and tracing library that wraps `loguru` for structured logging and integrates with OpenTelemetry to send traces to Grafana Tempo. +Structured JSON logging and optional OpenTelemetry tracing for Python services. -## Features -- JSON structured logging via `loguru` -- OpenTelemetry tracing with automatic span context propagation -- Supports external tracing backends like Grafana Tempo and Jaeger -- Configurable via environment variables +## Core Features (zero dependencies) -## Installation +- **`JSONLogFormatter`** — stdlib `logging.Formatter` that outputs one JSON object per line, matching the Go zap logger schema +- **`configure_logging()`** — one-call root logger setup with dev/prod dual mode -### Using uv (recommended) -```bash -# Install uv if you haven't already -curl -LsSf https://astral.sh/uv/install.sh | sh +## Optional Features -# Clone the repository -git clone https://github.com/nullify/pylogtracer.git -cd pylogtracer +Install extras for additional capabilities: -# Install dependencies -uv sync +| Extra | Provides | Dependencies | +|-------|----------|-------------| +| `[tracing]` | OpenTelemetry tracer setup | opentelemetry-api, -sdk, -exporter-otlp | +| `[loguru]` | `StructuredLogger` (loguru-based) | loguru | +| `[all]` | Everything above | all of the above | + +## Installation -# Install in development mode -uv pip install -e . +### Core only (recommended for most services) + +```bash +pip install pylogtracer ``` -### Using pip +### With tracing support + ```bash -pip install git+ssh://git@github.com/nullify/pylogtracer.git +pip install "pylogtracer[tracing]" ``` -## Development +### With all extras -### Setup with uv ```bash -# Install development dependencies -uv sync --extra dev +pip install "pylogtracer[all]" +``` -# Run tests -uv run pytest +## Usage -# Run linting -uv run ruff check . +### JSON Structured Logging -# Format code -uv run ruff format . +```python +import logging +from pylogtracer import configure_logging + +# Set up once at service startup +configure_logging() + +logger = logging.getLogger(__name__) +logger.info("Processing request", extra={"request_id": "abc-123", "tool": "search"}) ``` -### Using traditional pip +**Production output** (single JSON line): +```json +{"timestamp":"2025-06-01T12:34:56.789Z","level":"info","msg":"Processing request","caller":"app.py:8","logger":"__main__","request_id":"abc-123","tool":"search"} +``` + +**Development output** (set `DEVELOPMENT_MODE=true`): +``` +2025-06-01 12:34:56,789 - __main__ - INFO - Processing request +``` + +### JSON Field Schema + +| Field | Description | Go zap equivalent | +|-------|-------------|-------------------| +| `timestamp` | ISO 8601 UTC with `Z` suffix | `zapcore.ISO8601TimeEncoder` | +| `level` | `debug`, `info`, `warn`, `error`, `fatal` | `zapcore.LowercaseLevelEncoder` | +| `msg` | Log message | `MessageKey: "msg"` | +| `caller` | `filename:lineno` | `zapcore.ShortCallerEncoder` | +| `logger` | Logger name | `NameKey: "logger"` | +| `stacktrace` | Present on exceptions only | `StacktraceKey: "stacktrace"` | + +Any `extra={}` dict entries are promoted to top-level JSON keys. + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `DEVELOPMENT_MODE` | Set to `true` or `1` for human-readable text output | _(JSON mode)_ | +| `PYTHON_LOG_LEVEL` | Log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | `INFO` | + +## Development + ```bash -# Install development dependencies -pip install -e ".[dev]" +# Install dev dependencies +uv sync --all-groups # Run tests -pytest - -# Run linting -ruff check . +make test-python -# Format code -ruff format . +# Lint and format +make lint-python +make fix-python ``` diff --git a/pylogtracer/pylogtracer/__init__.py b/pylogtracer/pylogtracer/__init__.py index 1bb9ba5..bd0c27e 100644 --- a/pylogtracer/pylogtracer/__init__.py +++ b/pylogtracer/pylogtracer/__init__.py @@ -1,93 +1,43 @@ -import os +""" +PyLogTracer -- Structured logging and optional tracing for Python services. -import boto3 -from opentelemetry import trace -from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter +Core (zero external dependencies): + - ``JSONLogFormatter``: stdlib ``logging.Formatter`` producing Go zap-compatible JSON + - ``configure_logging``: one-call root logger setup (JSON in prod, text in dev) -from .logger import structured_logger -from .tracer import track +Optional extras (install via ``pip install pylogtracer[tracing]``, etc.): + - ``structured_logger``: loguru-based structured logger (requires ``[loguru]``) + - ``track``: OpenTelemetry span tracing decorator (requires ``[tracing,loguru]``) + - ``initialize_tracer``: OpenTelemetry tracer setup (requires ``[tracing]``) +""" -__all__ = ["structured_logger", "track"] +# Always available -- stdlib only +from .config import configure_logging +from .formatter import JSONLogFormatter +__all__ = [ + "JSONLogFormatter", + "configure_logging", +] -def get_secret_from_param_store(param_name_env): - """Fetch secret from AWS Parameter Store""" - param_name = os.getenv(param_name_env) - if not param_name: - return None - try: - ssm = boto3.client("ssm") - response = ssm.get_parameter(Name=param_name, WithDecryption=True) - return response["Parameter"]["Value"] - except Exception as e: - print(f"Failed to fetch parameter {param_name}: {e}") - return None +def __getattr__(name: str): # noqa: N807 + """Lazy-load optional modules to avoid import errors when extras are not installed.""" + if name == "structured_logger": + from .logger import structured_logger + return structured_logger + if name == "track": + from .tracer import track -def create_exporter(): - """Create appropriate span exporter based on environment configuration""" - if endpoint := os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"): - # Check for headers in Parameter Store - headers = {} - if headers_param := get_secret_from_param_store( - "OTEL_EXPORTER_OTLP_HEADERS_NAME" - ): - try: - # Parse headers string "key1=value1,key2=value2" format - headers = dict( - pair.split("=", 1) - for pair in headers_param.split(",") - if "=" in pair - ) - except Exception as e: - print(f"Failed to parse headers: {e}") - try: - traces_endpoint = endpoint + "/v1/traces" + return track + if name == "initialize_tracer": + from .tracing_setup import initialize_tracer - return OTLPSpanExporter(endpoint=traces_endpoint, headers=headers) - except Exception as e: - print(f"Failed to create OTLP exporter: {e}") + return initialize_tracer + if name == "tracer": + from .tracing_setup import tracer - # Fall back to console exporter if TRACE_OUTPUT_DEBUG is set - if os.getenv("TRACE_OUTPUT_DEBUG"): - try: - return ConsoleSpanExporter() - except Exception as e: - print(f"Failed to create console exporter: {e}") - - return None - - -def initialize_tracer(): - """Initialize the OpenTelemetry tracer""" - # Create resource with service information - resource = Resource.create( - { - "service.name": os.getenv("OTEL_SERVICE_NAME", "pylogtrace-service"), - "service.namespace": os.getenv("OTEL_SERVICE_NAMESPACE", "default"), - "deployment.environment": os.getenv( - "DEPLOYMENT_ENVIRONMENT", "development" - ), - "service.version": os.getenv("SERVICE_VERSION", "0.0.0"), - } - ) - - # Set up tracer provider with resource - provider = TracerProvider(resource=resource) - trace.set_tracer_provider(provider) - - # # Configure exporter - if exporter := create_exporter(): - provider.add_span_processor(BatchSpanProcessor(exporter)) - else: - structured_logger.warn("No exporter created") - - return trace.get_tracer(__name__) - - -# Initialize the tracer -tracer = initialize_tracer() + return tracer + msg = f"module 'pylogtracer' has no attribute {name!r}" + raise AttributeError(msg) diff --git a/pylogtracer/pylogtracer/config.py b/pylogtracer/pylogtracer/config.py new file mode 100644 index 0000000..70968ef --- /dev/null +++ b/pylogtracer/pylogtracer/config.py @@ -0,0 +1,61 @@ +""" +Logging configuration for Python services. + +Provides ``configure_logging()`` which sets up the root logger with either +structured JSON output (production) or human-readable text (development). +""" + +import logging +import os +import sys + +from .formatter import JSONLogFormatter + + +def configure_logging( + log_level: str = "INFO", + enable_http_debug: bool = False, + suppress_langfuse: bool = True, +) -> None: + """Configure the root logger for service execution. + + In production (default) logs are emitted as structured JSON matching + the Go zap schema. Set the ``DEVELOPMENT_MODE`` env var to ``true`` + or ``1`` to fall back to human-readable text output. + + All output goes to stderr so that processes can write structured data + to stdout without corruption. + + Args: + log_level: Default log level (overridden by ``PYTHON_LOG_LEVEL`` env var). + enable_http_debug: Enable debug logging for urllib3/requests. + suppress_langfuse: Suppress verbose Langfuse logging. + """ + level_str = os.getenv("PYTHON_LOG_LEVEL", log_level).upper() + level = getattr(logging, level_str, logging.INFO) + + dev_mode = os.getenv("DEVELOPMENT_MODE", "").lower() in ("true", "1") + + # Clear any previously-configured handlers so behaviour is deterministic. + root = logging.getLogger() + root.handlers.clear() + root.setLevel(level) + + handler = logging.StreamHandler(sys.stderr) + handler.setLevel(level) + + if dev_mode: + handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + ) + else: + handler.setFormatter(JSONLogFormatter()) + + root.addHandler(handler) + + if suppress_langfuse: + logging.getLogger("langfuse").setLevel(logging.ERROR) + + if enable_http_debug: + logging.getLogger("urllib3").setLevel(logging.DEBUG) + logging.getLogger("requests").setLevel(logging.DEBUG) diff --git a/pylogtracer/pylogtracer/formatter.py b/pylogtracer/pylogtracer/formatter.py new file mode 100644 index 0000000..8684d4f --- /dev/null +++ b/pylogtracer/pylogtracer/formatter.py @@ -0,0 +1,91 @@ +""" +Structured JSON log formatter compatible with Go's zap logger schema. + +Outputs one JSON object per log line so that log aggregation tools can parse, +index, and alert on fields like level, caller, and any extra keys +passed via ``logging.info("msg", extra={...})``. + +Only uses stdlib modules -- no external dependencies. +""" + +import json +import logging +import traceback +from datetime import datetime, timezone + +# Map Python level names to Go zap equivalents so queries on +# ``level="warn"`` or ``level="fatal"`` match Python log output. +_LEVEL_NAME_MAP: dict[str, str] = { + "WARNING": "warn", + "CRITICAL": "fatal", +} + +# Standard LogRecord attributes that should NOT be forwarded as extras. +_STANDARD_LOG_RECORD_ATTRS: frozenset[str] = frozenset( + { + "args", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "levelname", + "levelno", + "lineno", + "message", + "module", + "msecs", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "stack_info", + "taskName", + "thread", + "threadName", + } +) + + +class JSONLogFormatter(logging.Formatter): + """Emit each log record as a single JSON line matching the Go zap schema. + + Fields produced: + timestamp - ISO 8601 UTC (e.g. ``2025-06-01T12:34:56.789Z``) + level - zap-compatible (debug, info, warn, error, fatal) + msg - the formatted log message + caller - ``filename:lineno`` + logger - the logger name (``record.name``) + stacktrace - present only when ``exc_info`` is set + + Any *extra* dict entries that are not standard ``LogRecord`` attributes + are promoted to top-level JSON keys. + """ + + def format(self, record: logging.LogRecord) -> str: + dt = datetime.fromtimestamp(record.created, tz=timezone.utc) + log_entry: dict[str, object] = { + "timestamp": dt.strftime("%Y-%m-%dT%H:%M:%S.") + + f"{dt.microsecond // 1000:03d}Z", + "level": _LEVEL_NAME_MAP.get(record.levelname, record.levelname.lower()), + "msg": record.getMessage(), + "caller": f"{record.filename}:{record.lineno}", + "logger": record.name, + } + + # Attach stacktrace when exception info is present. + if record.exc_info and record.exc_info[1] is not None: + # Single-arg form (Python 3.10+) -- avoids DeprecationWarning + # from the three-arg form removed in Python 3.14. + log_entry["stacktrace"] = "".join( + traceback.format_exception(record.exc_info[1]) + ).rstrip() + + # Forward extra fields as top-level keys. + for key, value in record.__dict__.items(): + if key not in _STANDARD_LOG_RECORD_ATTRS and key not in log_entry: + log_entry[key] = value + + return json.dumps(log_entry, default=str) diff --git a/pylogtracer/pylogtracer/tracing_setup.py b/pylogtracer/pylogtracer/tracing_setup.py new file mode 100644 index 0000000..4922fc5 --- /dev/null +++ b/pylogtracer/pylogtracer/tracing_setup.py @@ -0,0 +1,99 @@ +""" +OpenTelemetry tracer initialization. + +This module requires the ``[tracing]`` extra to be installed:: + + pip install pylogtracer[tracing] + +It is lazily loaded by ``pylogtracer.__init__`` so importing +``pylogtracer`` without the extra does not crash. +""" + +import os + + +def get_secret_from_param_store(param_name_env: str) -> str | None: + """Fetch a secret from AWS Systems Manager Parameter Store. + + Args: + param_name_env: Name of the env var that holds the SSM parameter path. + + Returns: + The decrypted parameter value, or ``None`` on failure. + """ + param_name = os.getenv(param_name_env) + if not param_name: + return None + + try: + import boto3 + + ssm = boto3.client("ssm") + response = ssm.get_parameter(Name=param_name, WithDecryption=True) + return response["Parameter"]["Value"] + except Exception as e: # noqa: BLE001 + print(f"Failed to fetch parameter {param_name}: {e}") # noqa: T201 + return None + + +def create_exporter(): + """Create an OTLP or console span exporter based on environment variables.""" + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.sdk.trace.export import ConsoleSpanExporter + + if endpoint := os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"): + headers: dict[str, str] = {} + if headers_param := get_secret_from_param_store( + "OTEL_EXPORTER_OTLP_HEADERS_NAME" + ): + try: + headers = dict( + pair.split("=", 1) + for pair in headers_param.split(",") + if "=" in pair + ) + except Exception as e: # noqa: BLE001 + print(f"Failed to parse headers: {e}") # noqa: T201 + try: + return OTLPSpanExporter(endpoint=endpoint + "/v1/traces", headers=headers) + except Exception as e: # noqa: BLE001 + print(f"Failed to create OTLP exporter: {e}") # noqa: T201 + + if os.getenv("TRACE_OUTPUT_DEBUG"): + try: + return ConsoleSpanExporter() + except Exception as e: # noqa: BLE001 + print(f"Failed to create console exporter: {e}") # noqa: T201 + + return None + + +def initialize_tracer(): + """Initialize the OpenTelemetry tracer provider and return a tracer.""" + from opentelemetry import trace + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + resource = Resource.create( + { + "service.name": os.getenv("OTEL_SERVICE_NAME", "pylogtrace-service"), + "service.namespace": os.getenv("OTEL_SERVICE_NAMESPACE", "default"), + "deployment.environment": os.getenv( + "DEPLOYMENT_ENVIRONMENT", "development" + ), + "service.version": os.getenv("SERVICE_VERSION", "0.0.0"), + } + ) + + provider = TracerProvider(resource=resource) + trace.set_tracer_provider(provider) + + if exporter := create_exporter(): + provider.add_span_processor(BatchSpanProcessor(exporter)) + + return trace.get_tracer(__name__) + + +# Lazily initialized -- only runs when this module is actually imported. +tracer = initialize_tracer() diff --git a/pylogtracer/tests/__init__.py b/pylogtracer/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pylogtracer/tests/test_config.py b/pylogtracer/tests/test_config.py new file mode 100644 index 0000000..858614a --- /dev/null +++ b/pylogtracer/tests/test_config.py @@ -0,0 +1,82 @@ +"""Tests for pylogtracer.config.configure_logging.""" + +import logging +import sys + +from pylogtracer.config import configure_logging +from pylogtracer.formatter import JSONLogFormatter + + +class TestConfigureLogging: + def setup_method(self): + """Reset root logger between tests.""" + root = logging.getLogger() + root.handlers.clear() + root.setLevel(logging.WARNING) + # Reset langfuse logger level (may be set by previous tests). + logging.getLogger("langfuse").setLevel(logging.NOTSET) + + def test_prod_mode_uses_json_formatter(self, monkeypatch): + monkeypatch.delenv("DEVELOPMENT_MODE", raising=False) + configure_logging() + root = logging.getLogger() + assert len(root.handlers) == 1 + assert isinstance(root.handlers[0].formatter, JSONLogFormatter) + + def test_dev_mode_uses_text_formatter(self, monkeypatch): + monkeypatch.setenv("DEVELOPMENT_MODE", "true") + configure_logging() + root = logging.getLogger() + assert len(root.handlers) == 1 + assert not isinstance(root.handlers[0].formatter, JSONLogFormatter) + + def test_dev_mode_flag_one(self, monkeypatch): + monkeypatch.setenv("DEVELOPMENT_MODE", "1") + configure_logging() + root = logging.getLogger() + assert not isinstance(root.handlers[0].formatter, JSONLogFormatter) + + def test_respects_python_log_level_env(self, monkeypatch): + monkeypatch.delenv("DEVELOPMENT_MODE", raising=False) + monkeypatch.setenv("PYTHON_LOG_LEVEL", "DEBUG") + configure_logging() + root = logging.getLogger() + assert root.level == logging.DEBUG + + def test_default_level_is_info(self, monkeypatch): + monkeypatch.delenv("DEVELOPMENT_MODE", raising=False) + monkeypatch.delenv("PYTHON_LOG_LEVEL", raising=False) + configure_logging() + root = logging.getLogger() + assert root.level == logging.INFO + + def test_outputs_to_stderr(self, monkeypatch): + monkeypatch.delenv("DEVELOPMENT_MODE", raising=False) + configure_logging() + root = logging.getLogger() + handler = root.handlers[0] + assert isinstance(handler, logging.StreamHandler) + assert handler.stream is sys.stderr + + def test_suppresses_langfuse_by_default(self, monkeypatch): + monkeypatch.delenv("DEVELOPMENT_MODE", raising=False) + configure_logging() + langfuse_logger = logging.getLogger("langfuse") + assert langfuse_logger.level == logging.ERROR + + def test_does_not_suppress_langfuse_when_disabled(self, monkeypatch): + monkeypatch.delenv("DEVELOPMENT_MODE", raising=False) + configure_logging(suppress_langfuse=False) + langfuse_logger = logging.getLogger("langfuse") + assert langfuse_logger.level != logging.ERROR + + def test_clears_existing_handlers(self, monkeypatch): + monkeypatch.delenv("DEVELOPMENT_MODE", raising=False) + root = logging.getLogger() + before = len(root.handlers) + root.addHandler(logging.StreamHandler()) + root.addHandler(logging.StreamHandler()) + assert len(root.handlers) == before + 2 + configure_logging() + # configure_logging clears all existing handlers and adds exactly one. + assert len(root.handlers) == 1 diff --git a/pylogtracer/tests/test_formatter.py b/pylogtracer/tests/test_formatter.py new file mode 100644 index 0000000..113122d --- /dev/null +++ b/pylogtracer/tests/test_formatter.py @@ -0,0 +1,125 @@ +"""Tests for pylogtracer.formatter.JSONLogFormatter.""" + +import json +import logging +import warnings + +import pytest +from pylogtracer.formatter import JSONLogFormatter + + +@pytest.fixture +def formatter(): + return JSONLogFormatter() + + +def _make_record( + msg: str = "hello", + level: int = logging.INFO, + name: str = "test.logger", + exc_info: tuple | None = None, + extra: dict | None = None, +) -> logging.LogRecord: + record = logging.LogRecord( + name=name, + level=level, + pathname="example.py", + lineno=42, + msg=msg, + args=(), + exc_info=exc_info, + ) + if extra: + for key, value in extra.items(): + setattr(record, key, value) + return record + + +class TestJSONOutput: + def test_produces_valid_json(self, formatter: JSONLogFormatter): + record = _make_record() + output = formatter.format(record) + parsed = json.loads(output) + assert parsed["msg"] == "hello" + assert parsed["level"] == "info" + assert parsed["caller"] == "example.py:42" + assert parsed["logger"] == "test.logger" + assert "timestamp" in parsed + + def test_timestamp_format(self, formatter: JSONLogFormatter): + record = _make_record() + parsed = json.loads(formatter.format(record)) + ts = parsed["timestamp"] + assert ts.endswith("Z"), f"Timestamp should end with Z: {ts}" + # Should match YYYY-MM-DDTHH:MM:SS.mmmZ + assert len(ts) == 24, f"Unexpected timestamp length: {ts}" + + def test_preserves_extra_fields(self, formatter: JSONLogFormatter): + record = _make_record(extra={"request_id": "abc-123", "tool": "git_diff"}) + parsed = json.loads(formatter.format(record)) + assert parsed["request_id"] == "abc-123" + assert parsed["tool"] == "git_diff" + + def test_extra_fields_do_not_override_core(self, formatter: JSONLogFormatter): + # Core fields like "level" and "caller" cannot be overridden by extras. + record = _make_record(extra={"level": "override", "caller": "bad:0"}) + parsed = json.loads(formatter.format(record)) + assert parsed["level"] == "info" # original level preserved + assert parsed["caller"] == "example.py:42" # original caller preserved + + +class TestLevelMapping: + @pytest.mark.parametrize( + ("py_level", "expected_zap"), + [ + (logging.DEBUG, "debug"), + (logging.INFO, "info"), + (logging.WARNING, "warn"), + (logging.ERROR, "error"), + (logging.CRITICAL, "fatal"), + ], + ids=["debug", "info", "warning->warn", "error", "critical->fatal"], + ) + def test_maps_levels_to_zap_equivalents( + self, + formatter: JSONLogFormatter, + py_level: int, + expected_zap: str, + ): + record = _make_record(level=py_level) + parsed = json.loads(formatter.format(record)) + assert parsed["level"] == expected_zap + + +class TestStacktrace: + def test_includes_stacktrace_on_exception(self, formatter: JSONLogFormatter): + try: + raise ValueError("test error") + except ValueError: + import sys + + exc_info = sys.exc_info() + + record = _make_record(exc_info=exc_info) + parsed = json.loads(formatter.format(record)) + assert "stacktrace" in parsed + assert "ValueError: test error" in parsed["stacktrace"] + + def test_no_stacktrace_without_exception(self, formatter: JSONLogFormatter): + record = _make_record() + parsed = json.loads(formatter.format(record)) + assert "stacktrace" not in parsed + + def test_format_exception_no_deprecation_warning(self, formatter: JSONLogFormatter): + """Verify single-arg form does not emit DeprecationWarning.""" + try: + raise RuntimeError("boom") + except RuntimeError: + import sys + + exc_info = sys.exc_info() + + record = _make_record(exc_info=exc_info) + with warnings.catch_warnings(): + warnings.simplefilter("error") + formatter.format(record) # should not raise diff --git a/pyproject.toml b/pyproject.toml index 0739469..30a276f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,19 @@ [project] name = "pylogtracer" -version = "0.1.0" -description = "Internal logging library with structured logs and tracing" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ +version = "0.2.0" +description = "Internal logging library with structured JSON logging and optional tracing" +readme = "pylogtracer/README.md" +requires-python = ">=3.11" +dependencies = [] # Core modules (formatter, config) are stdlib-only + +[project.optional-dependencies] +tracing = [ "opentelemetry-api>=1.30.0", "opentelemetry-sdk>=1.30.0", "opentelemetry-exporter-otlp>=1.30.0", - "loguru>=0.7.0", ] +loguru = ["loguru>=0.7.0"] +all = ["pylogtracer[tracing,loguru]"] [tool.uv] dev-dependencies = [ @@ -23,4 +27,4 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["agents", "models_python"] +packages = ["pylogtracer"] diff --git a/uv.lock b/uv.lock index 185c3db..2ea34b9 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 -revision = 2 -requires-python = ">=3.12" +revision = 3 +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.13'", "python_full_version < '3.13'", @@ -21,6 +21,19 @@ version = "3.4.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" }, + { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" }, { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, @@ -77,6 +90,16 @@ version = "1.74.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/38/b4/35feb8f7cab7239c5b94bd2db71abb3d6adb5f335ad8f131abb6060840b6/grpcio-1.74.0.tar.gz", hash = "sha256:80d1f4fbb35b0742d3e3d3bb654b7381cd5f015f8497279a1e9c21ba623e01b1", size = 12756048, upload-time = "2025-07-24T18:54:23.039Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/77/b2f06db9f240a5abeddd23a0e49eae2b6ac54d85f0e5267784ce02269c3b/grpcio-1.74.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:69e1a8180868a2576f02356565f16635b99088da7df3d45aaa7e24e73a054e31", size = 5487368, upload-time = "2025-07-24T18:53:03.548Z" }, + { url = "https://files.pythonhosted.org/packages/48/99/0ac8678a819c28d9a370a663007581744a9f2a844e32f0fa95e1ddda5b9e/grpcio-1.74.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8efe72fde5500f47aca1ef59495cb59c885afe04ac89dd11d810f2de87d935d4", size = 10999804, upload-time = "2025-07-24T18:53:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/45/c6/a2d586300d9e14ad72e8dc211c7aecb45fe9846a51e558c5bca0c9102c7f/grpcio-1.74.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:a8f0302f9ac4e9923f98d8e243939a6fb627cd048f5cd38595c97e38020dffce", size = 5987667, upload-time = "2025-07-24T18:53:07.157Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/5f338bf56a7f22584e68d669632e521f0de460bb3749d54533fc3d0fca4f/grpcio-1.74.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f609a39f62a6f6f05c7512746798282546358a37ea93c1fcbadf8b2fed162e3", size = 6655612, upload-time = "2025-07-24T18:53:09.244Z" }, + { url = "https://files.pythonhosted.org/packages/82/ea/a4820c4c44c8b35b1903a6c72a5bdccec92d0840cf5c858c498c66786ba5/grpcio-1.74.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98e0b7434a7fa4e3e63f250456eaef52499fba5ae661c58cc5b5477d11e7182", size = 6219544, upload-time = "2025-07-24T18:53:11.221Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/0537630a921365928f5abb6d14c79ba4dcb3e662e0dbeede8af4138d9dcf/grpcio-1.74.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:662456c4513e298db6d7bd9c3b8df6f75f8752f0ba01fb653e252ed4a59b5a5d", size = 6334863, upload-time = "2025-07-24T18:53:12.925Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a6/85ca6cb9af3f13e1320d0a806658dca432ff88149d5972df1f7b51e87127/grpcio-1.74.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3d14e3c4d65e19d8430a4e28ceb71ace4728776fd6c3ce34016947474479683f", size = 7019320, upload-time = "2025-07-24T18:53:15.002Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/fe2beab970a1e25d2eff108b3cf4f7d9a53c185106377a3d1989216eba45/grpcio-1.74.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bf949792cee20d2078323a9b02bacbbae002b9e3b9e2433f2741c15bdeba1c4", size = 6514228, upload-time = "2025-07-24T18:53:16.999Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c2/2f9c945c8a248cebc3ccda1b7a1bf1775b9d7d59e444dbb18c0014e23da6/grpcio-1.74.0-cp311-cp311-win32.whl", hash = "sha256:55b453812fa7c7ce2f5c88be3018fb4a490519b6ce80788d5913f3f9d7da8c7b", size = 3817216, upload-time = "2025-07-24T18:53:20.564Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d1/a9cf9c94b55becda2199299a12b9feef0c79946b0d9d34c989de6d12d05d/grpcio-1.74.0-cp311-cp311-win_amd64.whl", hash = "sha256:86ad489db097141a907c559988c29718719aa3e13370d40e20506f11b4de0d11", size = 4495380, upload-time = "2025-07-24T18:53:22.058Z" }, { url = "https://files.pythonhosted.org/packages/4c/5d/e504d5d5c4469823504f65687d6c8fb97b7f7bf0b34873b7598f1df24630/grpcio-1.74.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:8533e6e9c5bd630ca98062e3a1326249e6ada07d05acf191a77bc33f8948f3d8", size = 5445551, upload-time = "2025-07-24T18:53:23.641Z" }, { url = "https://files.pythonhosted.org/packages/43/01/730e37056f96f2f6ce9f17999af1556df62ee8dab7fa48bceeaab5fd3008/grpcio-1.74.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2918948864fec2a11721d91568effffbe0a02b23ecd57f281391d986847982f6", size = 10979810, upload-time = "2025-07-24T18:53:25.349Z" }, { url = "https://files.pythonhosted.org/packages/79/3d/09fd100473ea5c47083889ca47ffd356576173ec134312f6aa0e13111dee/grpcio-1.74.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:60d2d48b0580e70d2e1954d0d19fa3c2e60dd7cbed826aca104fff518310d1c5", size = 5941946, upload-time = "2025-07-24T18:53:27.387Z" }, @@ -298,10 +321,20 @@ wheels = [ [[package]] name = "pylogtracer" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } -dependencies = [ + +[package.optional-dependencies] +all = [ + { name = "loguru" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, +] +loguru = [ { name = "loguru" }, +] +tracing = [ { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp" }, { name = "opentelemetry-sdk" }, @@ -316,11 +349,13 @@ dev = [ [package.metadata] requires-dist = [ - { name = "loguru", specifier = ">=0.7.0" }, - { name = "opentelemetry-api", specifier = ">=1.30.0" }, - { name = "opentelemetry-exporter-otlp", specifier = ">=1.30.0" }, - { name = "opentelemetry-sdk", specifier = ">=1.30.0" }, + { name = "loguru", marker = "extra == 'loguru'", specifier = ">=0.7.0" }, + { name = "opentelemetry-api", marker = "extra == 'tracing'", specifier = ">=1.30.0" }, + { name = "opentelemetry-exporter-otlp", marker = "extra == 'tracing'", specifier = ">=1.30.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'tracing'", specifier = ">=1.30.0" }, + { name = "pylogtracer", extras = ["tracing", "loguru"], marker = "extra == 'all'" }, ] +provides-extras = ["tracing", "loguru", "all"] [package.metadata.requires-dev] dev = [ From bbc21316764147bfb2cfd1e5d0e3f7c471b3a12c Mon Sep 17 00:00:00 2001 From: Harvey Chui Date: Fri, 27 Feb 2026 17:56:33 +1100 Subject: [PATCH 2/2] [FDE-360] Address review feedback from dnjg - Replace __getattr__ magic with explicit get_structured_logger() and get_tracer() functions - Replace getattr(logging, ...) with logging.getLevelNamesMapping() - Add boto3 to [tracing] optional dependency - Rename get_secret_from_param_store to private _get_secret_from_param_store - Replace print() with logging.exception() for proper error visibility - Rename create_exporter to private _create_exporter - Remove module-level tracer initialization (callers use get_tracer()) Co-Authored-By: Claude Opus 4.6 --- pylogtracer/pylogtracer/__init__.py | 42 ++++++++------ pylogtracer/pylogtracer/config.py | 2 +- pylogtracer/pylogtracer/tracing_setup.py | 34 +++++------ pyproject.toml | 1 + uv.lock | 73 ++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 39 deletions(-) diff --git a/pylogtracer/pylogtracer/__init__.py b/pylogtracer/pylogtracer/__init__.py index bd0c27e..9e86b67 100644 --- a/pylogtracer/pylogtracer/__init__.py +++ b/pylogtracer/pylogtracer/__init__.py @@ -6,9 +6,9 @@ - ``configure_logging``: one-call root logger setup (JSON in prod, text in dev) Optional extras (install via ``pip install pylogtracer[tracing]``, etc.): - - ``structured_logger``: loguru-based structured logger (requires ``[loguru]``) - - ``track``: OpenTelemetry span tracing decorator (requires ``[tracing,loguru]``) - - ``initialize_tracer``: OpenTelemetry tracer setup (requires ``[tracing]``) + - ``get_structured_logger()``: loguru-based structured logger (requires ``[loguru]``) + - ``get_tracer()``: OpenTelemetry tracer (requires ``[tracing]``) + - ``initialize_tracer()``: OpenTelemetry tracer setup (requires ``[tracing]``) """ # Always available -- stdlib only @@ -18,26 +18,30 @@ __all__ = [ "JSONLogFormatter", "configure_logging", + "get_structured_logger", + "get_tracer", ] -def __getattr__(name: str): # noqa: N807 - """Lazy-load optional modules to avoid import errors when extras are not installed.""" - if name == "structured_logger": - from .logger import structured_logger +def get_structured_logger(): + """Return the loguru-based structured logger. - return structured_logger - if name == "track": - from .tracer import track + Requires the ``[loguru]`` extra:: - return track - if name == "initialize_tracer": - from .tracing_setup import initialize_tracer + pip install pylogtracer[loguru] + """ + from .logger import structured_logger - return initialize_tracer - if name == "tracer": - from .tracing_setup import tracer + return structured_logger - return tracer - msg = f"module 'pylogtracer' has no attribute {name!r}" - raise AttributeError(msg) + +def get_tracer(): + """Return an initialised OpenTelemetry tracer. + + Requires the ``[tracing]`` extra:: + + pip install pylogtracer[tracing] + """ + from .tracing_setup import initialize_tracer + + return initialize_tracer() diff --git a/pylogtracer/pylogtracer/config.py b/pylogtracer/pylogtracer/config.py index 70968ef..4314c81 100644 --- a/pylogtracer/pylogtracer/config.py +++ b/pylogtracer/pylogtracer/config.py @@ -32,7 +32,7 @@ def configure_logging( suppress_langfuse: Suppress verbose Langfuse logging. """ level_str = os.getenv("PYTHON_LOG_LEVEL", log_level).upper() - level = getattr(logging, level_str, logging.INFO) + level = logging.getLevelNamesMapping().get(level_str, logging.INFO) dev_mode = os.getenv("DEVELOPMENT_MODE", "").lower() in ("true", "1") diff --git a/pylogtracer/pylogtracer/tracing_setup.py b/pylogtracer/pylogtracer/tracing_setup.py index 4922fc5..53bde18 100644 --- a/pylogtracer/pylogtracer/tracing_setup.py +++ b/pylogtracer/pylogtracer/tracing_setup.py @@ -4,15 +4,15 @@ This module requires the ``[tracing]`` extra to be installed:: pip install pylogtracer[tracing] - -It is lazily loaded by ``pylogtracer.__init__`` so importing -``pylogtracer`` without the extra does not crash. """ +import logging import os +logger = logging.getLogger(__name__) + -def get_secret_from_param_store(param_name_env: str) -> str | None: +def _get_secret_from_param_store(param_name_env: str) -> str | None: """Fetch a secret from AWS Systems Manager Parameter Store. Args: @@ -31,19 +31,19 @@ def get_secret_from_param_store(param_name_env: str) -> str | None: ssm = boto3.client("ssm") response = ssm.get_parameter(Name=param_name, WithDecryption=True) return response["Parameter"]["Value"] - except Exception as e: # noqa: BLE001 - print(f"Failed to fetch parameter {param_name}: {e}") # noqa: T201 + except Exception: + logger.exception("Failed to fetch parameter %s", param_name) return None -def create_exporter(): +def _create_exporter(): """Create an OTLP or console span exporter based on environment variables.""" from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.trace.export import ConsoleSpanExporter if endpoint := os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"): headers: dict[str, str] = {} - if headers_param := get_secret_from_param_store( + if headers_param := _get_secret_from_param_store( "OTEL_EXPORTER_OTLP_HEADERS_NAME" ): try: @@ -52,18 +52,18 @@ def create_exporter(): for pair in headers_param.split(",") if "=" in pair ) - except Exception as e: # noqa: BLE001 - print(f"Failed to parse headers: {e}") # noqa: T201 + except Exception: + logger.exception("Failed to parse OTLP headers") try: return OTLPSpanExporter(endpoint=endpoint + "/v1/traces", headers=headers) - except Exception as e: # noqa: BLE001 - print(f"Failed to create OTLP exporter: {e}") # noqa: T201 + except Exception: + logger.exception("Failed to create OTLP exporter for %s", endpoint) if os.getenv("TRACE_OUTPUT_DEBUG"): try: return ConsoleSpanExporter() - except Exception as e: # noqa: BLE001 - print(f"Failed to create console exporter: {e}") # noqa: T201 + except Exception: + logger.exception("Failed to create console exporter") return None @@ -89,11 +89,7 @@ def initialize_tracer(): provider = TracerProvider(resource=resource) trace.set_tracer_provider(provider) - if exporter := create_exporter(): + if exporter := _create_exporter(): provider.add_span_processor(BatchSpanProcessor(exporter)) return trace.get_tracer(__name__) - - -# Lazily initialized -- only runs when this module is actually imported. -tracer = initialize_tracer() diff --git a/pyproject.toml b/pyproject.toml index 30a276f..46eddaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [] # Core modules (formatter, config) are stdlib-only [project.optional-dependencies] tracing = [ + "boto3>=1.35.0", "opentelemetry-api>=1.30.0", "opentelemetry-sdk>=1.30.0", "opentelemetry-exporter-otlp>=1.30.0", diff --git a/uv.lock b/uv.lock index 2ea34b9..f456343 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,34 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[[package]] +name = "boto3" +version = "1.42.58" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/35/02f91308eed91fb8351809e8319c204dce7672e8bb297395ed44395b7b97/boto3-1.42.58.tar.gz", hash = "sha256:3a21b5bbc8bf8d6472a7ae7bdc77819b1f86f35d127f428f4603bed1b98122c0", size = 112775, upload-time = "2026-02-26T20:25:21.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/47/3a5b53628311fef4a2cec5c04ff750376ecaac0e9eb7fbea1fa8a88ec198/boto3-1.42.58-py3-none-any.whl", hash = "sha256:1bc5ff0b7a1a3f42b115481e269e1aada1d68bbfa80a989ac2882d51072907a3", size = 140556, upload-time = "2026-02-26T20:25:18.543Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.58" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/f4/9466eee955c62af0430c0c608a50d460d017fb4609b29eba84c6473d04c6/botocore-1.42.58.tar.gz", hash = "sha256:55224d6a91afae0997e8bee62d1ef1ae2dcbc6c210516939b32a774b0b35bec5", size = 14942809, upload-time = "2026-02-26T20:25:07.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/e0/f957ed6434f922ceffddba6db308b23d1ec2206beacb166cb83a75c5af61/botocore-1.42.58-py3-none-any.whl", hash = "sha256:3098178f4404cf85c8997ebb7948b3f267cff1dd191b08fc4ebb614ac1013a20", size = 14616050, upload-time = "2026-02-26T20:25:02.609Z" }, +] + [[package]] name = "certifi" version = "2025.8.3" @@ -152,6 +180,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -326,6 +363,7 @@ source = { editable = "." } [package.optional-dependencies] all = [ + { name = "boto3" }, { name = "loguru" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp" }, @@ -335,6 +373,7 @@ loguru = [ { name = "loguru" }, ] tracing = [ + { name = "boto3" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp" }, { name = "opentelemetry-sdk" }, @@ -349,6 +388,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "boto3", marker = "extra == 'tracing'", specifier = ">=1.35.0" }, { name = "loguru", marker = "extra == 'loguru'", specifier = ">=0.7.0" }, { name = "opentelemetry-api", marker = "extra == 'tracing'", specifier = ">=1.30.0" }, { name = "opentelemetry-exporter-otlp", marker = "extra == 'tracing'", specifier = ">=1.30.0" }, @@ -392,6 +432,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "requests" version = "2.32.4" @@ -432,6 +484,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/9b/0b8aa09817b63e78d94b4977f18b1fcaead3165a5ee49251c5d5c245bb2d/ruff-0.12.7-py3-none-win_arm64.whl", hash = "sha256:dfce05101dbd11833a0776716d5d1578641b7fddb537fe7fa956ab85d1769b69", size = 11982083, upload-time = "2025-07-29T22:32:33.881Z" }, ] +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.1"